From 7938a52915eab4b5c22875b111c497e94b107326 Mon Sep 17 00:00:00 2001 From: Alexandre Tranchant Date: Fri, 2 Oct 2026 20:48:54 +0200 Subject: [PATCH] feat: Implement EWKT dimensional representation support and add tests --- README.md | 11 ++ lib/Strategy/EwktDecoderStrategy.php | 5 +- .../Wkt/Parser/WktGeometryParserRegistry.php | 10 +- lib/Strategy/Wkt/Parser/WktParserFactory.php | 10 +- lib/Strategy/Wkt/WktCoordinateReader.php | 17 ++- lib/Strategy/Wkt/WktDialect.php | 28 ++++ tests/Unit/EwktDimensionTest.php | 135 ++++++++++++++++++ 7 files changed, 203 insertions(+), 13 deletions(-) create mode 100644 lib/Strategy/Wkt/WktDialect.php create mode 100644 tests/Unit/EwktDimensionTest.php diff --git a/README.md b/README.md index dcb98e1..e36630e 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,17 @@ markers and EMPTY geometries. The optional `SRID=;` 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, diff --git a/lib/Strategy/EwktDecoderStrategy.php b/lib/Strategy/EwktDecoderStrategy.php index 38b202d..f602ab7 100644 --- a/lib/Strategy/EwktDecoderStrategy.php +++ b/lib/Strategy/EwktDecoderStrategy.php @@ -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; @@ -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(); @@ -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); } /** diff --git a/lib/Strategy/Wkt/Parser/WktGeometryParserRegistry.php b/lib/Strategy/Wkt/Parser/WktGeometryParserRegistry.php index 31d3067..7b6c11f 100644 --- a/lib/Strategy/Wkt/Parser/WktGeometryParserRegistry.php +++ b/lib/Strategy/Wkt/Parser/WktGeometryParserRegistry.php @@ -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; @@ -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) { } @@ -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); } diff --git a/lib/Strategy/Wkt/Parser/WktParserFactory.php b/lib/Strategy/Wkt/Parser/WktParserFactory.php index 52e320a..1493b49 100644 --- a/lib/Strategy/Wkt/Parser/WktParserFactory.php +++ b/lib/Strategy/Wkt/Parser/WktParserFactory.php @@ -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; /** @@ -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)); diff --git a/lib/Strategy/Wkt/WktCoordinateReader.php b/lib/Strategy/Wkt/WktCoordinateReader.php index b1c8dba..1ef3c35 100644 --- a/lib/Strategy/Wkt/WktCoordinateReader.php +++ b/lib/Strategy/Wkt/WktCoordinateReader.php @@ -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) { } @@ -191,11 +192,13 @@ 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 * @@ -203,9 +206,15 @@ private function consumeOrdinates(): array */ 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.'), }; } diff --git a/lib/Strategy/Wkt/WktDialect.php b/lib/Strategy/Wkt/WktDialect.php new file mode 100644 index 0000000..067d360 --- /dev/null +++ b/lib/Strategy/Wkt/WktDialect.php @@ -0,0 +1,28 @@ + 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; +} diff --git a/tests/Unit/EwktDimensionTest.php b/tests/Unit/EwktDimensionTest.php new file mode 100644 index 0000000..8418b90 --- /dev/null +++ b/tests/Unit/EwktDimensionTest.php @@ -0,0 +1,135 @@ + 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 */ + 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 */ + 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 */ + 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); + } +}