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