fix(kml): reconocer Placemark con cualquier xmlns + soporte KMZ

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 <name>/<description> 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 19:56:20 +02:00
co-authored by Claude Opus 4.7
parent 042233069f
commit a4554bb1ca
2 changed files with 134 additions and 33 deletions
+73 -33
View File
@@ -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;