From a4554bb1ca0daba3a2bdf106d7f24c4400a84665 Mon Sep 17 00:00:00 2001 From: javier Date: Tue, 7 Jul 2026 19:56:20 +0200 Subject: [PATCH] fix(kml): reconocer Placemark con cualquier xmlns + soporte KMZ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: kmlToGeoJson usaba xpath //kml:Placemark exigiendo el namespace http://www.opengis.net/kml/2.2. KMLs con xmlns de Google Earth (http://earth.google.com/kml/2.1) o sin namespace daban 0 placemarks → la capa se creaba pero SIN elementos. Fix: - Todos los xpath usan local-name() (namespace-agnostic). - parseKmlGeometry navega Point/LineString/Polygon/MultiGeometry sin depender del prefijo del namespace. - Soporte KMZ: descomprime el zip y parsea el .kml interno (habitualmente doc.kml). - Nuevo helper kmlChild() para leer / tolerante al ns. Tests: SpatialFileConverterTest cubre los tres KMLs (opengis 2.2, google 2.1, sin xmlns). Suite 94 passing. Co-Authored-By: Claude Opus 4.7 --- app/Services/SpatialFileConverter.php | 106 ++++++++++++++------- tests/Feature/SpatialFileConverterTest.php | 61 ++++++++++++ 2 files changed, 134 insertions(+), 33 deletions(-) create mode 100644 tests/Feature/SpatialFileConverterTest.php diff --git a/app/Services/SpatialFileConverter.php b/app/Services/SpatialFileConverter.php index aad11c9..6b7d4fe 100644 --- a/app/Services/SpatialFileConverter.php +++ b/app/Services/SpatialFileConverter.php @@ -16,6 +16,7 @@ class SpatialFileConverter $geojson = match ($ext) { 'geojson' => self::parseGeoJson($path), 'kml' => self::kmlToGeoJson($path), + 'kmz' => self::kmzToGeoJson($path), 'shp' => self::shapefileToGeoJson($path), 'zip' => self::handleZip($path), default => null, @@ -176,9 +177,10 @@ class SpatialFileConverter if (!$xml) return null; - $xml->registerXPathNamespace('kml', 'http://www.opengis.net/kml/2.2'); - - $placemarks = $xml->xpath('//kml:Placemark'); + // Namespace-agnostic: usamos local-name() en el XPath para aceptar KMLs + // con cualquier xmlns (opengis 2.2, earth.google 2.1/2.0, o sin xmlns). + $placemarks = $xml->xpath('//*[local-name()="Placemark"]'); + if ($placemarks === false) $placemarks = []; $features = []; @@ -190,54 +192,92 @@ class SpatialFileConverter 'type' => 'Feature', 'geometry' => $geom, 'properties' => [ - 'name' => (string)$pm->name, - 'description' => (string)$pm->description - ] + 'name' => trim((string) self::kmlChild($pm, 'name')), + 'description' => trim((string) self::kmlChild($pm, 'description')), + ], ]; } return ['type' => 'FeatureCollection', 'features' => $features]; } + /** Descomprime un KMZ y parsea el .kml interno. */ + private static function kmzToGeoJson(string $path): ?array + { + $zip = new \ZipArchive(); + if ($zip->open($path) !== true) return null; + + $tmp = sys_get_temp_dir() . '/kmz_' . uniqid(); + if (! @mkdir($tmp, 0777, true) && ! is_dir($tmp)) { + $zip->close(); + return null; + } + $zip->extractTo($tmp); + $zip->close(); + + // Buscar el primer .kml (habitualmente doc.kml) + $kml = null; + $rii = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tmp)); + foreach ($rii as $file) { + if ($file->isFile() && strtolower($file->getExtension()) === 'kml') { + $kml = $file->getPathname(); + break; + } + } + $result = $kml ? self::kmlToGeoJson($kml) : null; + + // Limpieza + self::rrmdir($tmp); + return $result; + } + + private static function rrmdir(string $dir): void + { + if (! is_dir($dir)) return; + foreach (scandir($dir) ?: [] as $item) { + if ($item === '.' || $item === '..') continue; + $p = $dir . DIRECTORY_SEPARATOR . $item; + is_dir($p) ? self::rrmdir($p) : @unlink($p); + } + @rmdir($dir); + } + + /** Devuelve el primer hijo cuyo local-name coincida (sin depender del prefijo). */ + private static function kmlChild(\SimpleXMLElement $node, string $name): \SimpleXMLElement|string + { + $matches = $node->xpath('./*[local-name()="' . $name . '"]'); + return $matches ? $matches[0] : ''; + } + private static function parseKmlGeometry($pm): ?array { - if (isset($pm->MultiGeometry)) { - $geoms = []; + // Todos los accesos van por xpath local-name para tolerar cualquier xmlns. + $find = fn (string $tag) => $pm->xpath('./*[local-name()="' . $tag . '"]'); + $findDeep = fn (\SimpleXMLElement $n, string $tag) => $n->xpath('.//*[local-name()="' . $tag . '"]'); - foreach ($pm->MultiGeometry->children() as $g) { + if ($multi = $find('MultiGeometry')) { + $geoms = []; + foreach ($multi[0]->children() as $g) { $parsed = self::parseKmlGeometry($g); if ($parsed) $geoms[] = $parsed; } - - return [ - 'type' => 'GeometryCollection', - 'geometries' => $geoms - ]; + return ['type' => 'GeometryCollection', 'geometries' => $geoms]; } - if (isset($pm->Point)) { - return [ - 'type' => 'Point', - 'coordinates' => self::parseKmlCoords((string)$pm->Point->coordinates)[0] - ]; + if ($point = $find('Point')) { + $coords = self::parseKmlCoords((string) ($findDeep($point[0], 'coordinates')[0] ?? '')); + return $coords ? ['type' => 'Point', 'coordinates' => $coords[0]] : null; } - if (isset($pm->LineString)) { - return [ - 'type' => 'LineString', - 'coordinates' => self::parseKmlCoords((string)$pm->LineString->coordinates) - ]; + if ($line = $find('LineString')) { + $coords = self::parseKmlCoords((string) ($findDeep($line[0], 'coordinates')[0] ?? '')); + return $coords ? ['type' => 'LineString', 'coordinates' => $coords] : null; } - if (isset($pm->Polygon)) { - return [ - 'type' => 'Polygon', - 'coordinates' => [ - self::parseKmlCoords( - (string)$pm->Polygon->outerBoundaryIs->LinearRing->coordinates - ) - ] - ]; + if ($poly = $find('Polygon')) { + $outer = $findDeep($poly[0], 'coordinates')[0] ?? ''; + $coords = self::parseKmlCoords((string) $outer); + return $coords ? ['type' => 'Polygon', 'coordinates' => [$coords]] : null; } return null; diff --git a/tests/Feature/SpatialFileConverterTest.php b/tests/Feature/SpatialFileConverterTest.php new file mode 100644 index 0000000..a72ae7d --- /dev/null +++ b/tests/Feature/SpatialFileConverterTest.php @@ -0,0 +1,61 @@ + + + + P1-3.7,40.4,0 + P2-3.6,40.5,0 + +'; + $g = SpatialFileConverter::convertToGeoJson($this->uploadedKml($kml)); + $this->assertNotNull($g); + $this->assertCount(2, $g['features']); + $this->assertEquals('Point', $g['features'][0]['geometry']['type']); + } + + public function test_kml_with_google_earth_namespace_still_yields_features(): void + { + // Este era el bug: xpath //kml:Placemark fallaba con este xmlns. + $kml = ' + + + Pilar 1-3.7,40.4 + +'; + $g = SpatialFileConverter::convertToGeoJson($this->uploadedKml($kml)); + $this->assertNotNull($g); + $this->assertCount(1, $g['features']); + $this->assertEquals('Pilar 1', $g['features'][0]['properties']['name']); + } + + public function test_kml_without_namespace_still_yields_features(): void + { + $kml = ' + + Muro + -3.7,40.4 -3.6,40.5 + +'; + $g = SpatialFileConverter::convertToGeoJson($this->uploadedKml($kml)); + $this->assertNotNull($g); + $this->assertCount(1, $g['features']); + $this->assertEquals('LineString', $g['features'][0]['geometry']['type']); + } +}