From 4c7573e308c5811b0fac07dc022e2f2dc11ccbde Mon Sep 17 00:00:00 2001 From: Daniele Barbaro Date: Tue, 8 Sep 2026 14:46:10 +0200 Subject: [PATCH] feat(validator): accept the legacy KML namespaces KmlValidator hardcoded the OGC 2.2 namespace and rejected everything else, so any document exported before the OGC took the format over (earth.google.com/kml/2.0, 2.1 and 2.2, still common in the wild) failed validation with "Invalid or missing KML namespace" and could not be parsed at all. The accepted namespaces are now a list, configurable through the new supported_namespaces key, and XPath is registered against the namespace the document actually declares rather than the one we assumed. That last part is what makes a 2.1 document parse end to end instead of validating and then returning no placemarks. The validator also ignored the kml-parser.namespace config entirely, even though KmlParser read it. Setting that key made every document invalid, since validation still demanded 2.2. KmlParser now passes the configured namespaces to the validator, so the key finally does what it says. KmlValidator keeps working standalone, defaulting to the known namespaces without touching the container. --- README.md | 20 +++++++++- config/kml-parser.php | 18 +++++++++ src/KmlParser.php | 17 +++++++- src/Validators/KmlValidator.php | 50 +++++++++++++++++++++-- tests/NamespaceSupportTest.php | 71 +++++++++++++++++++++++++++++++++ 5 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 tests/NamespaceSupportTest.php diff --git a/README.md b/README.md index 3948c33..92c1bc5 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,25 @@ return [ | */ 'namespace' => 'http://www.opengis.net/kml/2.2', - + + /* + |-------------------------------------------------------------------------- + | Accepted KML Namespaces + |-------------------------------------------------------------------------- + | + | A document is rejected unless it declares one of these as its default + | namespace. 2.2 is the OGC standard; the earth.google.com variants come + | from Google Earth and older exporters and are still common in the wild. + | XPath always runs against whichever one the document actually declares. + | + */ + 'supported_namespaces' => [ + 'http://www.opengis.net/kml/2.2', + 'http://earth.google.com/kml/2.2', + 'http://earth.google.com/kml/2.1', + 'http://earth.google.com/kml/2.0', + ], + /* |-------------------------------------------------------------------------- | Temporary Directory diff --git a/config/kml-parser.php b/config/kml-parser.php index 4833743..8ca3448 100644 --- a/config/kml-parser.php +++ b/config/kml-parser.php @@ -13,6 +13,24 @@ */ 'namespace' => 'http://www.opengis.net/kml/2.2', + /* + |-------------------------------------------------------------------------- + | Accepted KML Namespaces + |-------------------------------------------------------------------------- + | + | A document is rejected unless it declares one of these as its default + | namespace. 2.2 is the OGC standard; the earth.google.com variants come + | from Google Earth and older exporters and are still common in the wild. + | XPath always runs against whichever one the document actually declares. + | + */ + 'supported_namespaces' => [ + 'http://www.opengis.net/kml/2.2', + 'http://earth.google.com/kml/2.2', + 'http://earth.google.com/kml/2.1', + 'http://earth.google.com/kml/2.0', + ], + /* |-------------------------------------------------------------------------- | Temporary Directory diff --git a/src/KmlParser.php b/src/KmlParser.php index 4c62a8e..bf29ecb 100755 --- a/src/KmlParser.php +++ b/src/KmlParser.php @@ -21,7 +21,20 @@ class KmlParser public function __construct() { $this->namespace = config('kml-parser.namespace', $this->namespace); - $this->validator = new KmlValidator; + $this->validator = new KmlValidator($this->supportedNamespaces()); + } + + /** + * Namespaces a document is allowed to declare: the configured primary one + * plus every variant listed in the config. + * + * @return array + */ + protected function supportedNamespaces(): array + { + $supported = config('kml-parser.supported_namespaces', KmlValidator::DEFAULT_NAMESPACES); + + return array_values(array_unique(array_merge([$this->namespace], (array) $supported))); } /** @@ -59,7 +72,7 @@ public function loadFromString(string $content): self $this->validator->validateDocument($xml); $this->xml = $xml; - $this->xml->registerXPathNamespace('kml', $this->namespace); + $this->xml->registerXPathNamespace('kml', $this->validator->documentNamespace()); return $this; } diff --git a/src/Validators/KmlValidator.php b/src/Validators/KmlValidator.php index f8cc637..a4c3ca9 100644 --- a/src/Validators/KmlValidator.php +++ b/src/Validators/KmlValidator.php @@ -8,10 +8,47 @@ class KmlValidator { - protected string $namespace = 'http://www.opengis.net/kml/2.2'; + /** + * The namespaces a KML document is allowed to declare. + * + * 2.2 is the OGC standard. The earth.google.com variants predate the OGC + * taking the format over, and exports carrying them are still in wide + * circulation, so rejecting them outright rejects valid files. + * + * @var array + */ + public const DEFAULT_NAMESPACES = [ + 'http://www.opengis.net/kml/2.2', + 'http://earth.google.com/kml/2.2', + 'http://earth.google.com/kml/2.1', + 'http://earth.google.com/kml/2.0', + ]; + + /** @var array */ + protected array $namespaces; + + protected string $documentNamespace = ''; protected SimpleXMLElement $xml; + /** + * @param array|null $namespaces Accepted namespaces, defaults to DEFAULT_NAMESPACES. + */ + public function __construct(?array $namespaces = null) + { + $namespaces = array_values(array_filter($namespaces ?? self::DEFAULT_NAMESPACES)); + + $this->namespaces = $namespaces !== [] ? $namespaces : self::DEFAULT_NAMESPACES; + } + + /** + * The namespace declared by the document that was validated last. + */ + public function documentNamespace(): string + { + return $this->documentNamespace; + } + /** * Parse and validate raw KML content. * @@ -46,11 +83,18 @@ public function validateDocument(SimpleXMLElement $xml): void $this->xml = $xml; $namespaces = $xml->getDocNamespaces(); - if (! isset($namespaces['']) || $namespaces[''] !== $this->namespace) { + $declared = $namespaces[''] ?? null; + + if ($declared === null || ! in_array($declared, $this->namespaces, true)) { throw new KmlException('Invalid or missing KML namespace'); } - $xml->registerXPathNamespace('kml', $this->namespace); + /* + * XPath has to run against the namespace the document actually + * declares, not the one we would have preferred it to use. + */ + $this->documentNamespace = $declared; + $xml->registerXPathNamespace('kml', $declared); if (empty($xml->Document)) { throw new KmlException('Missing required element: Document'); diff --git a/tests/NamespaceSupportTest.php b/tests/NamespaceSupportTest.php new file mode 100644 index 0000000..c5018cb --- /dev/null +++ b/tests/NamespaceSupportTest.php @@ -0,0 +1,71 @@ + + + + Legacy export + + Lago Blu + + 7.7300965,45.8635629,0 + + + + +XML; +} + +it('parses a document in the OGC 2.2 namespace', function () { + $parser = (new KmlParser)->loadFromString(kmlIn('http://www.opengis.net/kml/2.2')); + + expect($parser->getPlacemarks())->toHaveCount(1) + ->and($parser->getDocumentName())->toBe('Legacy export'); +}); + +it('parses documents in the legacy Google namespaces', function (string $namespace) { + $parser = (new KmlParser)->loadFromString(kmlIn($namespace)); + + expect($parser->getPlacemarks())->toHaveCount(1) + ->and($parser->getPlacemarks()[0]['name'])->toBe('Lago Blu') + ->and($parser->getDocumentName())->toBe('Legacy export'); +})->with([ + 'http://earth.google.com/kml/2.2', + 'http://earth.google.com/kml/2.1', + 'http://earth.google.com/kml/2.0', +]); + +it('still rejects a namespace that is not KML', function () { + expect(fn () => (new KmlParser)->loadFromString(kmlIn('http://wrong.namespace'))) + ->toThrow(KmlException::class, 'Invalid or missing KML namespace'); +}); + +it('accepts a namespace added through the config', function () { + config()->set('kml-parser.supported_namespaces', ['http://example.test/kml']); + + $parser = (new KmlParser)->loadFromString(kmlIn('http://example.test/kml')); + + expect($parser->getPlacemarks())->toHaveCount(1); +}); + +it('keeps accepting the configured primary namespace', function () { + config()->set('kml-parser.namespace', 'http://example.test/kml'); + config()->set('kml-parser.supported_namespaces', []); + + $parser = (new KmlParser)->loadFromString(kmlIn('http://example.test/kml')); + + expect($parser->getPlacemarks())->toHaveCount(1); +}); + +it('exposes the namespace the document declared', function () { + $validator = new KmlValidator; + $validator->validateDocument(new SimpleXMLElement(kmlIn('http://earth.google.com/kml/2.1'))); + + expect($validator->documentNamespace())->toBe('http://earth.google.com/kml/2.1'); +});