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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ markers and EMPTY geometries. The optional `SRID=<value>;` prefix is accepted
only before the root geometry; the reference applies to all collection members.
Coordinates retain their original order and values.

EWKT accepts separated (`POINT Z`, `POINT M`, `POINT ZM`) and compact
(`POINTZ`, `POINTM`, `POINTZM`) dimensional markers. Without a marker or an
inherited collection dimension, two ordinates resolve to XY, three to XYZ,
and four to XYZM. For example, `POINT (1 2 3)` and `POINTZ (1 2 3)` produce
the same XYZ point. XYM requires an explicit M marker, which collection
members may inherit. Tuples must match the resolved dimension, and conflicting
markers or mixed dimensions are rejected. Unmarked EMPTY geometries retain
the existing XY default unless a collection dimension is inherited.
These rules apply with or without an SRID prefix. Standard WKT decoding
continues to require explicit markers for non-XY coordinates.

SRIDs are unsigned decimal integers from `0` through `PHP_INT_MAX`, with leading
zeroes accepted. Prefix keywords are case-insensitive and whitespace between
tokens is accepted by the shared lexer. Negative values, signs, fractions,
Expand Down
5 changes: 3 additions & 2 deletions lib/Strategy/EwktDecoderStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use LongitudeOne\SpatialDecoder\Exception\InvalidArgumentException;
use LongitudeOne\SpatialDecoder\Strategy\Common\Lexer;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\Parser\WktParserFactory;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\WktDialect;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\WktTokenCursor;
use LongitudeOne\SpatialTypes\Interfaces\SpatialInterface;

Expand All @@ -36,7 +37,7 @@ public function decode(string $data): SpatialInterface
{
$cursor = new WktTokenCursor($data);
if (!$cursor->isNextToken(Lexer::T_SRID)) {
return (new WktParserFactory())->createFromCursor($cursor)->parse();
return (new WktParserFactory())->createFromCursor($cursor, WktDialect::EWKT)->parse();
}

$cursor->moveNext();
Expand All @@ -54,7 +55,7 @@ public function decode(string $data): SpatialInterface

$this->consume($cursor, Lexer::T_SEMICOLON);

return (new WktParserFactory())->createFromCursor($cursor)->parse()->withSrid($srid);
return (new WktParserFactory())->createFromCursor($cursor, WktDialect::EWKT)->parse()->withSrid($srid);
}

/**
Expand Down
10 changes: 7 additions & 3 deletions lib/Strategy/Wkt/Parser/WktGeometryParserRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use LongitudeOne\SpatialDecoder\Exception\InvalidArgumentException;
use LongitudeOne\SpatialDecoder\Exception\LogicException;
use LongitudeOne\SpatialDecoder\Strategy\Common\Lexer;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\WktDialect;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\WktTokenCursor;
use LongitudeOne\SpatialTypes\Interfaces\SpatialInterface;

Expand All @@ -37,9 +38,10 @@ final class WktGeometryParserRegistry implements WktGeometryParserDispatcherInte
/**
* Construct a registry for one WKT token stream.
*
* @param WktTokenCursor $cursor lexer cursor shared by registered parsers
* @param WktTokenCursor $cursor lexer cursor shared by registered parsers
* @param WktDialect $dialect dimensional syntax accepted by the representation
*/
public function __construct(private WktTokenCursor $cursor)
public function __construct(private WktTokenCursor $cursor, private WktDialect $dialect = WktDialect::WKT)
{
}

Expand All @@ -60,7 +62,9 @@ public function parseNext(?CoordinateDimensionEnum $inheritedDimension): Spatial
}

$this->cursor->moveNext();
$this->rejectCompactDimensionSuffix($geometryToken);
if (WktDialect::WKT === $this->dialect) {
$this->rejectCompactDimensionSuffix($geometryToken);
}

return $parser->parse($inheritedDimension);
}
Expand Down
10 changes: 6 additions & 4 deletions lib/Strategy/Wkt/Parser/WktParserFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use LongitudeOne\SpatialDecoder\Strategy\Wkt\Factory\WktPointFactory;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\Factory\WktPolygonFactory;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\WktCoordinateReader;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\WktDialect;
use LongitudeOne\SpatialDecoder\Strategy\Wkt\WktTokenCursor;

/**
Expand Down Expand Up @@ -55,15 +56,16 @@ public function create(string $input): WktParser
/**
* Create a geometry parser from a cursor positioned after any EWKT prefix.
*
* @param WktTokenCursor $cursor shared cursor positioned at the root geometry
* @param WktTokenCursor $cursor shared cursor positioned at the root geometry
* @param WktDialect $dialect dimensional syntax accepted by the representation
*/
public function createFromCursor(WktTokenCursor $cursor): WktParser
public function createFromCursor(WktTokenCursor $cursor, WktDialect $dialect = WktDialect::WKT): WktParser
{
$coordinateReader = new WktCoordinateReader($cursor);
$coordinateReader = new WktCoordinateReader($cursor, $dialect);
$pointFactory = new WktPointFactory();
$lineStringFactory = new WktLineStringFactory($pointFactory);
$polygonFactory = new WktPolygonFactory($lineStringFactory);
$registry = new WktGeometryParserRegistry($cursor);
$registry = new WktGeometryParserRegistry($cursor, $dialect);

$registry->register(Lexer::T_POINT, new PointWktParser($cursor, $coordinateReader, $pointFactory));
$registry->register(Lexer::T_LINESTRING, new LineStringWktParser($cursor, $coordinateReader, $lineStringFactory));
Expand Down
17 changes: 13 additions & 4 deletions lib/Strategy/Wkt/WktCoordinateReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ final class WktCoordinateReader
/**
* Construct a coordinate reader.
*
* @param WktTokenCursor $cursor lexer cursor for the WKT input
* @param WktTokenCursor $cursor lexer cursor for the WKT input
* @param WktDialect $dialect dimensional syntax accepted by the representation
*/
public function __construct(private WktTokenCursor $cursor)
public function __construct(private WktTokenCursor $cursor, private WktDialect $dialect = WktDialect::WKT)
{
}

Expand Down Expand Up @@ -191,21 +192,29 @@ private function consumeOrdinates(): array
}

/**
* Infer XY for unmarked coordinates and reject all other coordinate dimensions.
* Resolve unmarked coordinates according to the selected representation.
*
* OGC Simple Feature Access 1.2.1 (OGC 06-103r4) defines unmarked
* coordinates in its 2D grammar (§7.2.2). Higher coordinate dimensions
* use separate grammars tagged Z, M, and ZM (§7.2.3 to §7.2.5).
* EWKT follows the approved PostGIS extension: 3 ordinates imply XYZ
* and 4 imply XYZM; XYM always requires an M marker.
*
* @param int $ordinateCount number of ordinates in the coordinate
*
* @throws InvalidArgumentException when a non-XY dimension lacks its marker
*/
private function dimensionForUnmarkedCoordinate(int $ordinateCount): CoordinateDimensionEnum
{
if (WktDialect::WKT === $this->dialect && 2 !== $ordinateCount) {
throw $this->cursor->createInvalidInputException('A WKT coordinate with a non-XY dimension requires an explicit dimension marker.');
}

return match ($ordinateCount) {
2 => CoordinateDimensionEnum::XY,
default => throw $this->cursor->createInvalidInputException('A WKT coordinate with a non-XY dimension requires an explicit dimension marker.'),
3 => CoordinateDimensionEnum::XYZ,
4 => CoordinateDimensionEnum::XYZM,
default => throw $this->cursor->createInvalidInputException('An EWKT coordinate must contain two, three or four ordinates.'),
};
}

Expand Down
28 changes: 28 additions & 0 deletions lib/Strategy/Wkt/WktDialect.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php
/**
* This file is part of the spatial-decoder project.
*
* PHP 8.4 | 8.5
*
* Copyright Alexandre Tranchant <alexandre.tranchant@gmail.com> 2026
* Copyright Longitude One 2026
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
*/

declare(strict_types=1);

namespace LongitudeOne\SpatialDecoder\Strategy\Wkt;

/**
* Select the dimensional syntax accepted by the shared text parser.
*
* @internal
*/
enum WktDialect
{
case EWKT;
case WKT;
}
135 changes: 135 additions & 0 deletions tests/Unit/EwktDimensionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php
/**
* This file is part of the spatial-decoder project.
*
* PHP 8.4 | 8.5
*
* Copyright Alexandre Tranchant <alexandre.tranchant@gmail.com> 2026
* Copyright Longitude One 2026
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
*/

declare(strict_types=1);

namespace LongitudeOne\SpatialDecoder\Tests\Unit;

use LongitudeOne\SpatialDecoder\Exception\InvalidArgumentException;
use LongitudeOne\SpatialDecoder\Strategy\EwktDecoderStrategy;
use LongitudeOne\SpatialDecoder\Strategy\WktDecoderStrategy;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/** @internal */
class EwktDimensionTest extends TestCase
{
/** @return iterable<string, array{string}> */
public static function ewktOnlyRepresentations(): iterable
{
yield 'implicit XYZ' => ['POINT (1 2 3)'];
yield 'implicit XYZM' => ['POINT (1 2 3 4)'];
yield 'compact XYZ' => ['POINTZ (1 2 3)'];
yield 'compact XYM' => ['POINTM (1 2 3)'];
yield 'compact XYZM' => ['POINTZM (1 2 3 4)'];
}

/** @return iterable<string, array{string}> */
public static function invalidDimensions(): iterable
{
yield 'missing ordinates' => ['POINT ()'];
yield 'one ordinate' => ['POINT (1)'];
yield 'five ordinates' => ['POINT (1 2 3 4 5)'];
yield 'short Z' => ['POINTZ (1 2)'];
yield 'long Z' => ['POINT Z (1 2 3 4)'];
yield 'short M' => ['POINT M (1 2)'];
yield 'long M' => ['POINTM (1 2 3 4)'];
yield 'short ZM' => ['POINTZM (1 2 3)'];
yield 'duplicate marker' => ['POINT Z Z (1 2 3)'];
yield 'conflicting markers' => ['POINTZ M (1 2 3)'];
yield 'mixed line' => ['LINESTRING (1 2 3,4 5)'];
yield 'mixed points' => ['MULTIPOINT (1 2,3 4 5)'];
yield 'mixed lines' => ['MULTILINESTRING ((1 2 3,4 5 6),(1 2,3 4))'];
yield 'mixed polygon' => ['POLYGON ((0 0 1,4 0 2,4 4,0 0 1))'];
yield 'conflicting children' => ['GEOMETRYCOLLECTION (POINT Z (1 2 3),POINT M (4 5 6))'];
yield 'conflicting parent' => ['GEOMETRYCOLLECTIONM (POINTZ (1 2 3))'];
yield 'mixed children' => ['GEOMETRYCOLLECTION (POINT (1 2 3),POINT (4 5 6 7))'];
}

/** @return iterable<string, array{string, string}> */
public static function representations(): iterable
{
yield 'XY' => ['POINT (1 2)', 'POINT (1 2)'];
yield 'implicit XYZ' => ['POINT (1 2 3)', 'POINT Z (1 2 3)'];
yield 'implicit XYZM' => ['POINT (1 2 3 4)', 'POINT ZM (1 2 3 4)'];
yield 'compact XYZ' => ['POINTZ (1 2 3)', 'POINT Z (1 2 3)'];
yield 'compact XYM' => ['POINTM (1 2 3)', 'POINT M (1 2 3)'];
yield 'compact XYZM' => ['POINTZM (1 2 3 4)', 'POINT ZM (1 2 3 4)'];
yield 'explicit XYZ' => ['POINT Z (1 2 3)', 'POINT Z (1 2 3)'];
yield 'explicit XYM' => ['POINT M (1 2 3)', 'POINT M (1 2 3)'];
yield 'explicit XYZM' => ['POINT ZM (1 2 3 4)', 'POINT ZM (1 2 3 4)'];
yield 'compact line' => ['LINESTRINGZ (1 2 3,4 5 6)', 'LINESTRING Z (1 2 3,4 5 6)'];
yield 'compact polygon' => ['POLYGONM ((0 0 1,4 0 2,4 4 3,0 0 1))', 'POLYGON M ((0 0 1,4 0 2,4 4 3,0 0 1))'];
yield 'compact multi point' => ['MULTIPOINTZM ((1 2 3 4))', 'MULTIPOINT ZM ((1 2 3 4))'];
yield 'compact multi line' => ['MULTILINESTRINGM ((1 2 3,4 5 6))', 'MULTILINESTRING M ((1 2 3,4 5 6))'];
yield 'compact multi polygon' => ['MULTIPOLYGONZ (((0 0 1,4 0 2,4 4 3,0 0 1)))', 'MULTIPOLYGON Z (((0 0 1,4 0 2,4 4 3,0 0 1)))'];
yield 'numeric syntax' => ['point(1e1 -2.5 +3)', 'POINT Z (10 -2.5 3)'];
yield 'line' => ['LINESTRING (1 2 3,4 5 6)', 'LINESTRING Z (1 2 3,4 5 6)'];
yield 'polygon' => ['POLYGON ((0 0 1,4 0 2,4 4 3,0 0 1))', 'POLYGON Z ((0 0 1,4 0 2,4 4 3,0 0 1))'];
yield 'bare multi point' => ['MULTIPOINT (1 2 3 4,5 6 7 8)', 'MULTIPOINT ZM (1 2 3 4,5 6 7 8)'];
yield 'multi point with empty' => ['MULTIPOINT (EMPTY,(1 2 3))', 'MULTIPOINT Z (EMPTY,(1 2 3))'];
yield 'multi line' => ['MULTILINESTRING ((1 2 3,4 5 6))', 'MULTILINESTRING Z ((1 2 3,4 5 6))'];
yield 'multi polygon' => ['MULTIPOLYGON (((0 0 1,4 0 2,4 4 3,0 0 1)))', 'MULTIPOLYGON Z (((0 0 1,4 0 2,4 4 3,0 0 1)))'];
yield 'nested collection' => ['GEOMETRYCOLLECTION (POINT (1 2 3),GEOMETRYCOLLECTION (POINTZ (4 5 6),POINT EMPTY))', 'GEOMETRYCOLLECTION Z (POINT (1 2 3),GEOMETRYCOLLECTION (POINT (4 5 6),POINT EMPTY))'];
yield 'inherited M' => ['GEOMETRYCOLLECTIONM (POINT (1 2 3))', 'GEOMETRYCOLLECTION M (POINT (1 2 3))'];
yield 'unmarked empty' => ['POINT EMPTY', 'POINT EMPTY'];
yield 'empty XYZ' => ['POINTZ EMPTY', 'POINT Z EMPTY'];
yield 'empty XYM' => ['LINESTRINGM EMPTY', 'LINESTRING M EMPTY'];
yield 'empty XYZM' => ['GEOMETRYCOLLECTIONZM EMPTY', 'GEOMETRYCOLLECTION ZM EMPTY'];
}

/**
* @param string $ewkt extended representation
* @param string $wkt equivalent explicit WKT fixture
*/
#[DataProvider('representations')]
public function testEquivalentRepresentations(string $ewkt, string $wkt): void
{
$expected = (new WktDecoderStrategy())->decode($wkt);
$strategy = new EwktDecoderStrategy();
self::assertEquals($expected, $strategy->decode($ewkt));
self::assertEquals($expected->withSrid(4326), $strategy->decode('SRID=4326;'.$ewkt));
}

/** @param string $input invalid dimensional input */
#[DataProvider('invalidDimensions')]
public function testInvalidDimensions(string $input): void
{
$this->expectException(InvalidArgumentException::class);
(new EwktDecoderStrategy())->decode($input);
}

/** Explicitly distinguish the third ordinate's meaning. */
public function testThirdOrdinateSemantics(): void
{
$strategy = new EwktDecoderStrategy();
$xyz = $strategy->decode('POINT (1 2 3)');
$xym = $strategy->decode('POINTM (1 2 3)');
self::assertTrue($xyz->hasZ());
self::assertFalse($xyz->hasM());
self::assertFalse($xym->hasZ());
self::assertTrue($xym->hasM());
self::assertSame([1, 2, 3], $xyz->toArray());
self::assertSame([1, 2, 3], $xym->toArray());
}

/** @param string $input EWKT-only dimensional syntax */
#[DataProvider('ewktOnlyRepresentations')]
public function testWktStillRequiresMarkers(string $input): void
{
(new EwktDecoderStrategy())->decode($input);
$this->expectException(InvalidArgumentException::class);
(new WktDecoderStrategy())->decode($input);
}
}
Loading