From ba2b92a6c5c2e725b530b3e4df6700557550edb6 Mon Sep 17 00:00:00 2001 From: Daniele Barbaro Date: Tue, 8 Sep 2026 15:28:40 +0200 Subject: [PATCH] docs(readme): document what the parser returns The README showed which methods exist but never what they give back, so the only way to learn the shape of a placemark, a style or the GeoJSON output was to run the parser and print it. It also said nothing about which KML elements are supported, which matters more now that MultiGeometry, LineStyle, PolyStyle and SimpleData landed: users had no way to know those work, or that Folder and NetworkLink do not. Adds a supported elements table, the exact array shape of every getter, the accepted namespaces, the exception hierarchy, and notes on the parts that surprise people: MultiGeometry has geometries instead of coordinates, absent style keys are not defaults, colours stay in KML aabbggrr order, and the facade binding is scoped rather than a singleton. Also adds the missing CONTRIBUTING.md, which the README has linked to since the first release. --- CONTRIBUTING.md | 39 ++++++ README.md | 328 +++++++++++++++++++++++++++++++++++++----------- 2 files changed, 297 insertions(+), 70 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f104edb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# Contributing + +Thanks for taking the time to contribute. + +## Reporting a bug + +Open an issue with the KML or KMZ that reproduces it, or the smallest fragment of it that still does. A namespace declaration and one Placemark is usually enough. Say which version of the package, PHP and Laravel you are on. + +If the file is confidential, strip the coordinates and the names. The structure is what matters. + +## Requesting support for a KML element + +The README lists which elements are parsed and which are ignored. If you need one from the second list, open an issue with a sample document and what you expect to get back from `getPlacemarks()` or `toGeoJson()`. + +## Pull requests + +```bash +composer install +composer test +composer analyse +composer format +``` + +All three have to pass. CI runs them on every pull request, across PHP 8.3 and 8.4, Laravel 11 and 12, on Linux and Windows. + +A few things that make review quick: + +- **Add a test that fails without your change.** For a bugfix, write it first and check that it fails on `main`. For a new element, assert the exact array shape you expect, not just that something came back. +- **Run `composer format`.** The project uses Pint and CI will otherwise commit the formatting for you. +- **Keep the change focused.** Unrelated cleanups in the same pull request make it harder to tell what actually changed. +- **Say what breaks.** Changing the shape of a returned array is a breaking change even when no test notices. Call it out in the description so it reaches the changelog. + +## Coordinate and namespace changes + +Validation is deliberately strict: coordinates outside the valid ranges and unknown namespaces are rejected at load time rather than passed through. If you hit a real document that fails validation, that is a bug worth reporting, but the fix is usually to accept a specific new namespace or format rather than to loosen the check. + +## Security + +Do not open a public issue for a security problem. See the [security policy](../../security/policy). diff --git a/README.md b/README.md index 494c0dc..ea9858c 100644 --- a/README.md +++ b/README.md @@ -9,110 +9,250 @@ [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/plin-code/kml-parser/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/plin-code/kml-parser/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) [![Total Downloads](https://img.shields.io/packagist/dt/plin-code/kml-parser.svg?style=flat-square)](https://packagist.org/packages/plin-code/kml-parser) -A simple Laravel package to parse KML and KMZ files, extracting geographic data in a convenient format. +Parse KML and KMZ files in a Laravel application. Read placemarks, styles and document metadata as plain arrays, or convert the whole thing to GeoJSON. -## Installation +## Requirements + +PHP 8.3 or later, Laravel 11 or 12, and the `simplexml`, `libxml` and `zip` extensions. -You can install the package via composer: +## Installation ```bash composer require plin-code/kml-parser ``` -You can publish the config file with: +Publish the config file if you need to change anything: ```bash php artisan vendor:publish --tag="kml-parser-config" ``` -This is the contents of the published config file: +## Quick start ```php -return [ - /* - |-------------------------------------------------------------------------- - | Default KML Namespace - |-------------------------------------------------------------------------- - | - | This value is the default namespace used for parsing KML files. - | Usually you don't need to change this. - | - */ - 'namespace' => 'http://www.opengis.net/kml/2.2', +use PlinCode\KmlParser\KmlParser; - /* - |-------------------------------------------------------------------------- - | 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', +$parser = new KmlParser(); +$parser->loadFromFile('path/to/file.kml'); + +$placemarks = $parser->getPlacemarks(); +$styles = $parser->getStyles(); +$styleMaps = $parser->getStyleMaps(); +$name = $parser->getDocumentName(); +$description = $parser->getDocumentDescription(); +$geoJson = $parser->toGeoJson(); +``` + +A KMZ archive works the same way, `loadFromKmz()` picks the KML out of the archive for you: + +```php +$parser->loadFromKmz('path/to/file.kmz'); +``` + +You can also load from a string with `loadFromString()`. + +## Supported KML elements + +KML is a large format and this package covers a subset of it. What that subset is: + +| Element | Supported | Notes | +|---|---|---| +| `Document` | yes | required, `name` and `description` are read | +| `Placemark` | yes | `name`, `description`, `styleUrl`, geometry | +| `Point` | yes | | +| `LineString` | yes | | +| `Polygon` | yes | outer boundary plus any number of inner boundaries | +| `MultiGeometry` | yes | nests, and maps to a GeoJSON `GeometryCollection` | +| `Style` | yes | only styles carrying an `id`, see below | +| `StyleMap` | yes | `normal` and `highlight` pairs | +| `IconStyle` | yes | `scale`, `Icon/href`, `hotSpot` | +| `LabelStyle` | yes | `scale`, `color` | +| `LineStyle` | yes | `color`, `width` | +| `PolyStyle` | yes | `color`, `fill`, `outline` | +| `ExtendedData` | yes | both `Data` pairs and `SchemaData/SimpleData` entries | +| `Folder` | no | folders are flattened, the hierarchy is lost | +| `NetworkLink` | no | | +| `GroundOverlay`, `ScreenOverlay`, `PhotoOverlay` | no | | +| `TimeStamp`, `TimeSpan` | no | | +| `LookAt`, `Camera`, `Region` | no | | +| `gx:Track` and the rest of the `gx:` extensions | no | | + +A `Style` declared inline on a Placemark has no `id` and cannot be referenced by a `styleUrl`, so `getStyles()` skips it. Only shared styles appear in the returned map. + +Anything in the "no" column is ignored rather than rejected. A document using those elements still parses, you just do not get that data back. + +## What you get back + +### `getPlacemarks(): array` + +A list, one entry per Placemark, in document order. `name` and `description` are always present, `styleUrl` and `extendedData` only when the Placemark declares them. + +A `Point` carries a single position: + +```php +[ + 'name' => 'Lago Blu', + 'description' => 'A lake', + 'type' => 'Point', + 'coordinates' => [ + 'longitude' => 7.7, + 'latitude' => 45.8, + 'altitude' => 10.0, ], + 'styleUrl' => '#pair', + 'extendedData' => ['area' => '12'], +] +``` - /* - |-------------------------------------------------------------------------- - | Temporary Directory - |-------------------------------------------------------------------------- - | - | This value determines the temporary directory used for extracting KMZ files. - | If null, the system temp directory will be used. - | - */ - 'temp_directory' => null, -]; +A `LineString` carries a list of them: + +```php +[ + 'name' => 'Route', + 'description' => '', + 'type' => 'LineString', + 'coordinates' => [ + ['longitude' => 7.1, 'latitude' => 45.1, 'altitude' => 0.0], + ['longitude' => 7.2, 'latitude' => 45.2, 'altitude' => 0.0], + ], +] +``` + +A `Polygon` splits its rings: + +```php +[ + 'name' => 'Area', + 'description' => '', + 'type' => 'Polygon', + 'coordinates' => [ + 'outerBoundary' => [ + ['longitude' => 7.0, 'latitude' => 45.0, 'altitude' => 0.0], + // ... + ], + 'innerBoundaries' => [ + [ + ['longitude' => 7.1, 'latitude' => 45.1, 'altitude' => 0.0], + // ... + ], + ], + ], +] ``` -## Usage +`innerBoundaries` is an empty array when the polygon has no holes. -### Basic Usage +A `MultiGeometry` has no `coordinates` of its own. It carries `geometries` instead, each entry shaped exactly like a standalone geometry of that type, and it can nest: ```php -use PlinCode\KmlParser\KmlParser; +[ + 'name' => 'Mixed', + 'description' => '', + 'type' => 'MultiGeometry', + 'geometries' => [ + ['type' => 'Point', 'coordinates' => [...]], + ['type' => 'LineString', 'coordinates' => [...]], + ], +] +``` -// Parse a KML file -$parser = new KmlParser(); -$parser->loadFromFile('path/to/file.kml'); +So switch on `type` before reaching for `coordinates`. -// Get placemarks (points of interest) -$placemarks = $parser->getPlacemarks(); +### `getStyles(): array` -// Get styles -$styles = $parser->getStyles(); +Keyed by style `id`. Every sub-style is optional, and inside each one only the elements the document actually declares are reported: -// Get style maps -$styleMaps = $parser->getStyleMaps(); +```php +[ + 'pin' => [ + 'id' => 'pin', + 'iconStyle' => [ + 'scale' => 1.2, + 'href' => 'images/icon.png', + 'hotSpot' => ['x' => 32.0, 'y' => 64.0, 'xunits' => 'pixels', 'yunits' => 'insetPixels'], + ], + 'labelStyle' => ['scale' => 0.8, 'color' => 'ff112233'], + 'lineStyle' => ['color' => 'ff0000ff', 'width' => 4.0], + 'polyStyle' => ['color' => '7f00ff00', 'fill' => false, 'outline' => true], + ], +] +``` -// Get document name and description -$name = $parser->getDocumentName(); -$description = $parser->getDocumentDescription(); +KML defines defaults for all of these (`width` 1, `fill` and `outline` 1, and so on). The parser does not fill them in, so an absent key means the file said nothing about it, not that the value is the default. Apply your own defaults if you need them. -// Convert to GeoJSON -$geoJson = $parser->toGeoJson(); +`color` values are returned as the raw KML `aabbggrr` hex string, not converted to `rrggbb`. Note the byte order, KML puts alpha first and blue before red. + +### `getStyleMaps(): array` + +Keyed by StyleMap `id`: + +```php +[ + 'pair' => [ + 'id' => 'pair', + 'pairs' => [ + 'normal' => '#pin', + 'highlight' => '#pin', + ], + ], +] +``` + +### `getDocumentName(): ?string` and `getDocumentDescription(): ?string` + +The `name` and `description` of the first `Document` element, or `null` when absent. + +### `toGeoJson(): array` + +A `FeatureCollection`. Positions come out as `[longitude, latitude, altitude]`, which is the GeoJSON order, and altitude is always present, `0` when the file omits it. `styleUrl` and `extendedData` are carried into `properties`. + +```php +[ + 'type' => 'FeatureCollection', + 'features' => [ + [ + 'type' => 'Feature', + 'properties' => [ + 'name' => 'Lago Blu', + 'description' => 'A lake', + 'styleUrl' => '#pair', + 'extendedData' => ['area' => '12'], + ], + 'geometry' => [ + 'type' => 'Point', + 'coordinates' => [7.7, 45.8, 10.0], + ], + ], + ], +] ``` -### Working with KMZ Files +A `MultiGeometry` becomes a `GeometryCollection`. -KMZ files are ZIP archives that contain a KML file and possibly other assets like images: +The result is a PHP array, so encode it yourself when you need the wire format: ```php -// Parse a KMZ file -$parser = new KmlParser(); -$parser->loadFromKmz('path/to/file.kmz'); +return response()->json($parser->toGeoJson()); +``` -// Work with the data just like with KML -$placemarks = $parser->getPlacemarks(); +## KML namespaces + +KML predates the OGC, and exporters still emit the older Google namespaces. All four of these are accepted out of the box: + +``` +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 ``` -Extract all files from a KMZ: +A document declaring anything else is rejected with `Invalid or missing KML namespace`. Add your own through the `supported_namespaces` config key. XPath always runs against whichever namespace the document actually declares, so a 2.1 file is queried as 2.1. + +## KMZ files + +A KMZ is a ZIP archive holding a KML file and, usually, the icons it references. `loadFromKmz()` reads the first KML entry it finds and ignores the rest. + +To get at the other files, for example to serve the icons: ```php use PlinCode\KmlParser\KmzExtractor; @@ -121,9 +261,33 @@ $extractor = new KmzExtractor(); $files = $extractor->extractAllFiles('path/to/file.kmz', 'extraction/directory'); ``` -### Facade Usage +`extractAllFiles()` returns the list of entry names it wrote. It extracts whatever the archive contains, so point it at a directory you control and treat uploaded archives as untrusted input. + +## Error handling + +Everything the package throws extends `PlinCode\KmlParser\Exceptions\KmlException`, so one catch is enough to cover it: + +```php +use PlinCode\KmlParser\Exceptions\KmlException; + +try { + $placemarks = (new KmlParser())->loadFromFile($path)->getPlacemarks(); +} catch (KmlException $e) { + report($e); +} +``` + +Catch the subclasses when you need to tell the cases apart: + +| Exception | Thrown when | +|---|---| +| `KmlException` | the content is not valid KML: wrong namespace, no `Document`, a Placemark without geometry, coordinates out of range | +| `KmlParserException` | the file is missing, the XML does not parse, or a getter is called before anything was loaded | +| `KmzExtractorException` | the KMZ is missing, is not a readable ZIP, or holds no KML entry | + +Coordinate validation runs at load time, so `loadFromString()` rejects a longitude outside -180 to 180 or a latitude outside -90 to 90 before you ever see a placemark. -You can also use the provided facade: +## Facade ```php use PlinCode\KmlParser\Facades\KmlParser; @@ -131,10 +295,34 @@ use PlinCode\KmlParser\Facades\KmlParser; $placemarks = KmlParser::loadFromFile('path/to/file.kml')->getPlacemarks(); ``` +The facade resolves a container binding that is scoped to the request or queue job, so the loaded document does not carry over between requests under Octane or inside a long running worker. Keep the calls chained, or hold on to the instance, rather than assuming a later `KmlParser::getPlacemarks()` still sees what an earlier call loaded. + +## Configuration + +```php +return [ + // The primary namespace, also used as a fallback. + 'namespace' => 'http://www.opengis.net/kml/2.2', + + // Namespaces a document is allowed to declare. + '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', + ], + + // Reserved for KMZ extraction. Not used yet. + 'temp_directory' => null, +]; +``` + ## Testing ```bash composer test +composer analyse +composer format ``` ## Changelog