Skip to content

Commit 2c37af3

Browse files
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.
1 parent 8d7d4a6 commit 2c37af3

5 files changed

Lines changed: 421 additions & 98 deletions

File tree

src/KmlParser.php

Lines changed: 153 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace PlinCode\KmlParser;
44

55
use Exception;
6+
use PlinCode\KmlParser\Enums\GeometryType;
67
use PlinCode\KmlParser\Exceptions\KmlParserException;
78
use PlinCode\KmlParser\Traits\ParsesCoordinates;
89
use PlinCode\KmlParser\Validators\KmlValidator;
@@ -99,26 +100,16 @@ public function getPlacemarks(): array
99100
'description' => (string) $placemarkXml->description,
100101
];
101102

102-
if ($placemarkXml->Point) {
103-
$coords = (string) $placemarkXml->Point->coordinates;
104-
$coordsArray = explode(',', trim($coords));
105-
$placemark['type'] = 'Point';
106-
$placemark['coordinates'] = [
107-
'longitude' => (float) $coordsArray[0],
108-
'latitude' => (float) $coordsArray[1],
109-
'altitude' => isset($coordsArray[2]) ? (float) $coordsArray[2] : 0,
110-
];
111-
}
103+
foreach (GeometryType::cases() as $type) {
104+
if ($placemarkXml->{$type->value}) {
105+
$geometry = $this->parseGeometry($type, $placemarkXml->{$type->value});
112106

113-
if ($placemarkXml->LineString) {
114-
$coords = (string) $placemarkXml->LineString->coordinates;
115-
$placemark['type'] = 'LineString';
116-
$placemark['coordinates'] = $this->parseLineStringCoordinates($coords);
117-
}
107+
if ($geometry !== null) {
108+
$placemark = array_merge($placemark, $geometry);
109+
}
118110

119-
if ($placemarkXml->Polygon) {
120-
$placemark['type'] = 'Polygon';
121-
$placemark['coordinates'] = $this->parsePolygonCoordinates($placemarkXml->Polygon);
111+
break;
112+
}
122113
}
123114

124115
if ($placemarkXml->styleUrl) {
@@ -141,6 +132,60 @@ public function getPlacemarks(): array
141132
return $placemarks;
142133
}
143134

135+
/**
136+
* Turn one KML geometry element into its array representation.
137+
*
138+
* @return array<string, mixed>|null
139+
*/
140+
protected function parseGeometry(GeometryType $type, SimpleXMLElement $geometry): ?array
141+
{
142+
return match ($type) {
143+
GeometryType::POINT => [
144+
'type' => $type->value,
145+
'coordinates' => $this->parsePointCoordinates((string) $geometry->coordinates),
146+
],
147+
GeometryType::LINE_STRING => [
148+
'type' => $type->value,
149+
'coordinates' => $this->parseLineStringCoordinates((string) $geometry->coordinates),
150+
],
151+
GeometryType::POLYGON => [
152+
'type' => $type->value,
153+
'coordinates' => $this->parsePolygonCoordinates($geometry),
154+
],
155+
GeometryType::MULTI_GEOMETRY => [
156+
'type' => $type->value,
157+
'geometries' => $this->parseMultiGeometry($geometry),
158+
],
159+
};
160+
}
161+
162+
/**
163+
* A MultiGeometry holds nested geometries instead of coordinates, and KML
164+
* allows those to be MultiGeometry elements in turn.
165+
*
166+
* @return array<int, array<string, mixed>>
167+
*/
168+
protected function parseMultiGeometry(SimpleXMLElement $multiGeometry): array
169+
{
170+
$geometries = [];
171+
172+
foreach ($multiGeometry->children() as $name => $child) {
173+
$type = GeometryType::tryFrom((string) $name);
174+
175+
if ($type === null) {
176+
continue;
177+
}
178+
179+
$geometry = $this->parseGeometry($type, $child);
180+
181+
if ($geometry !== null) {
182+
$geometries[] = $geometry;
183+
}
184+
}
185+
186+
return $geometries;
187+
}
188+
144189
/**
145190
* Get Style Node from the KML
146191
*
@@ -250,86 +295,32 @@ public function getStyleMaps(): array
250295
public function toGeoJson(): array
251296
{
252297
$features = [];
253-
$placemarks = $this->getPlacemarks();
254-
255-
foreach ($placemarks as $placemark) {
256-
if (isset($placemark['coordinates'])) {
257-
$feature = [
258-
'type' => 'Feature',
259-
'properties' => [
260-
'name' => $placemark['name'],
261-
'description' => $placemark['description'],
262-
],
263-
];
264-
265-
// Set geometry based on type
266-
if ($placemark['type'] === 'Point') {
267-
$feature['geometry'] = [
268-
'type' => 'Point',
269-
'coordinates' => [
270-
$placemark['coordinates']['longitude'],
271-
$placemark['coordinates']['latitude'],
272-
$placemark['coordinates']['altitude'],
273-
],
274-
];
275-
} elseif ($placemark['type'] === 'LineString') {
276-
$coordinates = [];
277-
foreach ($placemark['coordinates'] as $coord) {
278-
$coordinates[] = [
279-
$coord['longitude'],
280-
$coord['latitude'],
281-
$coord['altitude'],
282-
];
283-
}
284-
285-
$feature['geometry'] = [
286-
'type' => 'LineString',
287-
'coordinates' => $coordinates,
288-
];
289-
} elseif ($placemark['type'] === 'Polygon') {
290-
$outerCoordinates = [];
291-
foreach ($placemark['coordinates']['outerBoundary'] as $coord) {
292-
$outerCoordinates[] = [
293-
$coord['longitude'],
294-
$coord['latitude'],
295-
$coord['altitude'],
296-
];
297-
}
298298

299-
$innerCoordinates = [];
300-
foreach ($placemark['coordinates']['innerBoundaries'] as $innerBoundary) {
301-
$innerBoundaryCoords = [];
302-
foreach ($innerBoundary as $coord) {
303-
$innerBoundaryCoords[] = [
304-
$coord['longitude'],
305-
$coord['latitude'],
306-
$coord['altitude'],
307-
];
308-
}
309-
$innerCoordinates[] = $innerBoundaryCoords;
310-
}
311-
312-
$allCoordinates = [$outerCoordinates];
313-
if (! empty($innerCoordinates)) {
314-
$allCoordinates = array_merge($allCoordinates, $innerCoordinates);
315-
}
299+
foreach ($this->getPlacemarks() as $placemark) {
300+
$geometry = $this->toGeoJsonGeometry($placemark);
316301

317-
$feature['geometry'] = [
318-
'type' => 'Polygon',
319-
'coordinates' => $allCoordinates,
320-
];
321-
}
302+
if ($geometry === null) {
303+
continue;
304+
}
322305

323-
if (isset($placemark['styleUrl'])) {
324-
$feature['properties']['styleUrl'] = $placemark['styleUrl'];
325-
}
306+
$feature = [
307+
'type' => 'Feature',
308+
'properties' => [
309+
'name' => $placemark['name'],
310+
'description' => $placemark['description'],
311+
],
312+
'geometry' => $geometry,
313+
];
326314

327-
if (isset($placemark['extendedData'])) {
328-
$feature['properties']['extendedData'] = $placemark['extendedData'];
329-
}
315+
if (isset($placemark['styleUrl'])) {
316+
$feature['properties']['styleUrl'] = $placemark['styleUrl'];
317+
}
330318

331-
$features[] = $feature;
319+
if (isset($placemark['extendedData'])) {
320+
$feature['properties']['extendedData'] = $placemark['extendedData'];
332321
}
322+
323+
$features[] = $feature;
333324
}
334325

335326
return [
@@ -338,6 +329,76 @@ public function toGeoJson(): array
338329
];
339330
}
340331

332+
/**
333+
* A KML MultiGeometry maps onto a GeoJSON GeometryCollection, which nests
334+
* the same way, so this recurses alongside parseMultiGeometry().
335+
*
336+
* @param array<string, mixed> $geometry
337+
* @return array<string, mixed>|null
338+
*/
339+
protected function toGeoJsonGeometry(array $geometry): ?array
340+
{
341+
return match ($geometry['type'] ?? null) {
342+
GeometryType::POINT->value => [
343+
'type' => 'Point',
344+
'coordinates' => $this->toGeoJsonPosition($geometry['coordinates']),
345+
],
346+
GeometryType::LINE_STRING->value => [
347+
'type' => 'LineString',
348+
'coordinates' => array_map(
349+
fn (array $position) => $this->toGeoJsonPosition($position),
350+
$geometry['coordinates'],
351+
),
352+
],
353+
GeometryType::POLYGON->value => [
354+
'type' => 'Polygon',
355+
'coordinates' => $this->toGeoJsonRings($geometry['coordinates']),
356+
],
357+
GeometryType::MULTI_GEOMETRY->value => [
358+
'type' => 'GeometryCollection',
359+
'geometries' => array_values(array_filter(array_map(
360+
fn (array $child) => $this->toGeoJsonGeometry($child),
361+
$geometry['geometries'],
362+
))),
363+
],
364+
default => null,
365+
};
366+
}
367+
368+
/**
369+
* @param array{longitude: float, latitude: float, altitude: float} $position
370+
* @return array<int, float>
371+
*/
372+
protected function toGeoJsonPosition(array $position): array
373+
{
374+
return [$position['longitude'], $position['latitude'], $position['altitude']];
375+
}
376+
377+
/**
378+
* GeoJSON puts the outer ring first and every inner ring after it.
379+
*
380+
* @param array{outerBoundary: array<int, array<string, float>>, innerBoundaries: array<int, array<int, array<string, float>>>} $boundaries
381+
* @return array<int, array<int, array<int, float>>>
382+
*/
383+
protected function toGeoJsonRings(array $boundaries): array
384+
{
385+
$rings = [
386+
array_map(
387+
fn (array $position) => $this->toGeoJsonPosition($position),
388+
$boundaries['outerBoundary'],
389+
),
390+
];
391+
392+
foreach ($boundaries['innerBoundaries'] as $innerBoundary) {
393+
$rings[] = array_map(
394+
fn (array $position) => $this->toGeoJsonPosition($position),
395+
$innerBoundary,
396+
);
397+
}
398+
399+
return $rings;
400+
}
401+
341402
/**
342403
* Get Document Node from the KML
343404
*

src/Traits/ParsesCoordinates.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@
66

77
trait ParsesCoordinates
88
{
9+
/**
10+
* @return array{longitude: float, latitude: float, altitude: float}
11+
*/
12+
protected function parsePointCoordinates(string $coordinates): array
13+
{
14+
$parts = explode(',', trim($coordinates));
15+
16+
return [
17+
'longitude' => (float) $parts[0],
18+
'latitude' => (float) ($parts[1] ?? 0),
19+
'altitude' => isset($parts[2]) ? (float) $parts[2] : 0,
20+
];
21+
}
22+
923
protected function parseLineStringCoordinates(string $coordinates): array
1024
{
1125
$coords = [];

src/Validators/KmlValidator.php

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,45 @@ public function validate(string $content): void
4747

4848
protected function validatePlacemark(SimpleXMLElement $placemark): void
4949
{
50-
$hasGeometry = false;
5150
foreach (GeometryType::cases() as $type) {
5251
if ($placemark->{$type->value}) {
53-
$hasGeometry = true;
54-
$this->validateGeometryCoordinates($placemark->{$type->value}, $type->value);
55-
break;
52+
$this->validateGeometry($placemark->{$type->value}, $type);
53+
54+
return;
55+
}
56+
}
57+
58+
throw new KmlException('Found Placemark without valid geometry');
59+
}
60+
61+
protected function validateGeometry(SimpleXMLElement $geometry, GeometryType $type): void
62+
{
63+
if ($type === GeometryType::MULTI_GEOMETRY) {
64+
$this->validateMultiGeometry($geometry);
65+
66+
return;
67+
}
68+
69+
$this->validateGeometryCoordinates($geometry, $type->value);
70+
}
71+
72+
/**
73+
* A MultiGeometry carries no coordinates of its own, only nested
74+
* geometries, and KML allows those to be MultiGeometry elements in turn.
75+
*/
76+
protected function validateMultiGeometry(SimpleXMLElement $multiGeometry): void
77+
{
78+
$found = false;
79+
80+
foreach (GeometryType::cases() as $type) {
81+
foreach ($multiGeometry->{$type->value} as $child) {
82+
$found = true;
83+
$this->validateGeometry($child, $type);
5684
}
5785
}
5886

59-
if (! $hasGeometry) {
60-
throw new KmlException('Found Placemark without valid geometry');
87+
if (! $found) {
88+
throw new KmlException('Found MultiGeometry without any geometry');
6189
}
6290
}
6391

0 commit comments

Comments
 (0)