Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions config/kml-parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/Exceptions/KmlParserException.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ public static function invalidXml(string $message): self
return new self("XML parsing error: {$message}");
}

/**
* @deprecated Nothing throws this any more. Malformed XML now surfaces as
* invalidXml(), and a validation failure keeps its own
* KmlException instead of being wrapped. Kept for one cycle so
* callers referencing it do not break; slated for removal in
* the next major.
*/
public static function failedToParse(string $message): self
{
return new self("Failed to parse KML content: {$message}");
Expand Down
43 changes: 27 additions & 16 deletions src/KmlParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,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<int, string>
*/
protected function supportedNamespaces(): array
{
$supported = config('kml-parser.supported_namespaces', KmlValidator::DEFAULT_NAMESPACES);

return array_values(array_unique(array_merge([$this->namespace], (array) $supported)));
}

/**
Expand All @@ -46,25 +59,23 @@ public function loadFromFile(string $path): self
*/
public function loadFromString(string $content): self
{
try {
$this->validator->validate($content);
libxml_use_internal_errors(true);
$this->xml = new SimpleXMLElement($content);

$errors = libxml_get_errors();
if ($errors) {
$errorMessage = $errors[0]->message;
libxml_clear_errors();
throw KmlParserException::invalidXml($errorMessage);
}
$previous = libxml_use_internal_errors(true);

$this->xml->registerXPathNamespace('kml', $this->namespace);

return $this;
try {
$xml = new SimpleXMLElement($content);
} catch (Exception $e) {
throw KmlParserException::invalidXml($e->getMessage());
} finally {
libxml_clear_errors();
throw KmlParserException::failedToParse($e->getMessage());
libxml_use_internal_errors($previous);
}

$this->validator->validateDocument($xml);

$this->xml = $xml;
$this->xml->registerXPathNamespace('kml', $this->validator->documentNamespace());

return $this;
}

/**
Expand Down
104 changes: 82 additions & 22 deletions src/Validators/KmlValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,40 +8,100 @@

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<int, string>
*/
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<int, string> */
protected array $namespaces;

protected string $documentNamespace = '';

protected SimpleXMLElement $xml;

public function validate(string $content): void
/**
* @param array<int, string>|null $namespaces Accepted namespaces, defaults to DEFAULT_NAMESPACES.
*/
public function __construct(?array $namespaces = null)
{
libxml_use_internal_errors(true);

try {
$this->xml = new SimpleXMLElement($content);
$namespaces = array_values(array_filter($namespaces ?? self::DEFAULT_NAMESPACES));

$namespaces = $this->xml->getDocNamespaces();
if (! isset($namespaces['']) || $namespaces[''] !== $this->namespace) {
throw new KmlException('Invalid or missing KML namespace');
}
$this->namespaces = $namespaces !== [] ? $namespaces : self::DEFAULT_NAMESPACES;
}

$this->xml->registerXPathNamespace('kml', $this->namespace);
/**
* The namespace declared by the document that was validated last.
*/
public function documentNamespace(): string
{
return $this->documentNamespace;
}

if (empty($this->xml->Document)) {
throw new KmlException('Missing required element: Document');
}
/**
* Parse and validate raw KML content.
*
* @throws KmlException
*/
public function validate(string $content): void
{
$previous = libxml_use_internal_errors(true);

$placemarks = $this->xml->xpath('//kml:Placemark');
if (! empty($placemarks)) {
foreach ($placemarks as $placemark) {
$this->validatePlacemark($placemark);
}
}
} catch (KmlException $e) {
throw $e;
try {
$xml = new SimpleXMLElement($content);
} catch (\Exception $e) {
throw new KmlException('Invalid KML content: '.$e->getMessage());
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}

$this->validateDocument($xml);
}

/**
* Validate an already parsed KML document.
*
* Callers that have parsed the document themselves should use this instead
* of validate(), so the content is not parsed twice.
*
* @throws KmlException
*/
public function validateDocument(SimpleXMLElement $xml): void
{
$this->xml = $xml;

$namespaces = $xml->getDocNamespaces();
$declared = $namespaces[''] ?? null;

if ($declared === null || ! in_array($declared, $this->namespaces, true)) {
throw new KmlException('Invalid or missing KML 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');
}

foreach ($xml->xpath('//kml:Placemark') ?: [] as $placemark) {
$this->validatePlacemark($placemark);
}
}

Expand Down
2 changes: 1 addition & 1 deletion tests/ExceptionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
$parser = new KmlParser;

$parser->loadFromString('invalid xml content');
})->throws(KmlParserException::class, 'Failed to parse KML content');
})->throws(KmlParserException::class, 'XML parsing error');

it('throws exception when KMZ file not found', function () {
$extractor = new KmzExtractor;
Expand Down
71 changes: 71 additions & 0 deletions tests/NamespaceSupportTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

use PlinCode\KmlParser\Exceptions\KmlException;
use PlinCode\KmlParser\KmlParser;
use PlinCode\KmlParser\Validators\KmlValidator;

function kmlIn(string $namespace): string
{
return <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="{$namespace}">
<Document>
<name>Legacy export</name>
<Placemark>
<name>Lago Blu</name>
<Point>
<coordinates>7.7300965,45.8635629,0</coordinates>
</Point>
</Placemark>
</Document>
</kml>
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');
});
Loading