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'); +});