• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

brick / geo / 17456208570

04 Sep 2025 07:10AM UTC coverage: 50.432%. Remained the same
17456208570

push

github

BenMorel
Use @extends and @implements instead of @template-* variants

For consistency with the rest of the project.

1867 of 3702 relevant lines covered (50.43%)

1140.21 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.11
/src/Io/GeoJsonReader.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Brick\Geo\Io;
6

7
use Brick\Geo\CoordinateSystem;
8
use Brick\Geo\Exception\GeometryException;
9
use Brick\Geo\Exception\GeometryIoException;
10
use Brick\Geo\Geometry;
11
use Brick\Geo\GeometryCollection;
12
use Brick\Geo\Io\GeoJson\Feature;
13
use Brick\Geo\Io\GeoJson\FeatureCollection;
14
use Brick\Geo\LineString;
15
use Brick\Geo\MultiLineString;
16
use Brick\Geo\MultiPoint;
17
use Brick\Geo\MultiPolygon;
18
use Brick\Geo\Point;
19
use Brick\Geo\Polygon;
20
use JsonException;
21
use stdClass;
22

23
use function count;
24
use function get_debug_type;
25
use function is_array;
26
use function is_object;
27
use function is_string;
28
use function json_decode;
29
use function property_exists;
30
use function sprintf;
31
use function strtolower;
32

33
use const JSON_THROW_ON_ERROR;
34

35
/**
36
 * Builds geometries out of GeoJSON text strings.
37
 */
38
final class GeoJsonReader
39
{
40
    /**
41
     * The GeoJSON types, in their correct case according to the standard, indexed by their lowercase counterpart.
42
     */
43
    private const TYPES = [
44
        'feature' => 'Feature',
45
        'featurecollection' => 'FeatureCollection',
46
        'point' => 'Point',
47
        'linestring' => 'LineString',
48
        'polygon' => 'Polygon',
49
        'multipoint' => 'MultiPoint',
50
        'multilinestring' => 'MultiLineString',
51
        'multipolygon' => 'MultiPolygon',
52
        'geometrycollection' => 'GeometryCollection',
53
    ];
54

55
    private readonly bool $lenient;
56

57
    /**
58
     * @param bool $lenient Whether to parse the GeoJSON in lenient mode.
59
     *                      This mode allows for some deviations from the GeoJSON spec:
60
     *                      - wrong case for GeoJSON types, e.g. POINT instead of Point,
61
     *                      - missing "geometry" or "properties" attributes in Features,
62
     *                      - nested GeometryCollections.
63
     */
64
    public function __construct(bool $lenient = false)
65
    {
66
        $this->lenient = $lenient;
1,544✔
67
    }
68

69
    /**
70
     * @throws GeometryException If the GeoJSON file is invalid.
71
     */
72
    public function read(string $geoJson): Geometry|Feature|FeatureCollection
73
    {
74
        try {
75
            $geoJsonObject = json_decode($geoJson, flags: JSON_THROW_ON_ERROR);
1,544✔
76
        } catch (JsonException $e) {
×
77
            throw GeometryIoException::invalidGeoJson('Unable to parse GeoJSON string.', $e);
×
78
        }
79

80
        if (! is_object($geoJsonObject)) {
1,544✔
81
            throw GeometryIoException::invalidGeoJson('GeoJSON string does not represent an object.');
×
82
        }
83

84
        /** @var stdClass $geoJsonObject */
85
        return $this->readAsObject($geoJsonObject);
1,544✔
86
    }
87

88
    /**
89
     * @throws GeometryException
90
     */
91
    private function readAsObject(stdClass $geoJsonObject): Geometry|Feature|FeatureCollection
92
    {
93
        if (! isset($geoJsonObject->type) || ! is_string($geoJsonObject->type)) {
1,544✔
94
            throw GeometryIoException::invalidGeoJson('Missing or malformed "type" attribute.');
×
95
        }
96

97
        $geoType = $this->normalizeGeoJsonType($geoJsonObject->type);
1,544✔
98

99
        switch ($geoType) {
100
            case 'Feature':
1,296✔
101
                return $this->readFeature($geoJsonObject);
608✔
102

103
            case 'FeatureCollection':
688✔
104
                return $this->readFeatureCollection($geoJsonObject);
48✔
105

106
            case 'Point':
640✔
107
            case 'LineString':
568✔
108
            case 'Polygon':
496✔
109
            case 'MultiPoint':
376✔
110
            case 'MultiLineString':
304✔
111
            case 'MultiPolygon':
232✔
112
            case 'GeometryCollection':
160✔
113
                return $this->readGeometry($geoJsonObject);
640✔
114

115
            default:
116
                throw GeometryIoException::unsupportedGeoJsonType($geoJsonObject->type);
×
117
        }
118
    }
119

120
    /**
121
     * @throws GeometryException
122
     */
123
    private function readFeature(stdClass $geoJsonFeature): Feature
124
    {
125
        $this->verifyType($geoJsonFeature, 'Feature');
656✔
126

127
        $geometry = null;
656✔
128

129
        if (property_exists($geoJsonFeature, 'geometry')) {
656✔
130
            if ($geoJsonFeature->geometry !== null) {
640✔
131
                if (! is_object($geoJsonFeature->geometry)) {
592✔
132
                    throw GeometryIoException::invalidGeoJson('Malformed "Feature.geometry" attribute.');
×
133
                }
134

135
                /** @var stdClass $geoJsonFeature->geometry */
136
                $geometry = $this->readGeometry($geoJsonFeature->geometry);
628✔
137
            }
138
        } elseif (! $this->lenient) {
16✔
139
            throw GeometryIoException::invalidGeoJson(
8✔
140
                'Missing "Feature.geometry" attribute. ' .
8✔
141
                'Features without geometry should use an explicit null value for this field. ' .
8✔
142
                'You can ignore this error by setting the $lenient flag to true.',
8✔
143
            );
8✔
144
        }
145

146
        $properties = null;
648✔
147

148
        if (property_exists($geoJsonFeature, 'properties')) {
648✔
149
            if ($geoJsonFeature->properties !== null) {
632✔
150
                if (! is_object($geoJsonFeature->properties)) {
560✔
151
                    throw GeometryIoException::invalidGeoJson('Malformed "Feature.properties" attribute.');
×
152
                }
153

154
                /** @var stdClass $properties */
155
                $properties = $geoJsonFeature->properties;
614✔
156
            }
157
        } elseif (! $this->lenient) {
16✔
158
            throw GeometryIoException::invalidGeoJson(
8✔
159
                'Missing "Feature.properties" attribute. ' .
8✔
160
                'Features without properties should use an explicit null value for this field. ' .
8✔
161
                'You can ignore this error by setting the $lenient flag to true.',
8✔
162
            );
8✔
163
        }
164

165
        return new Feature($geometry, $properties);
640✔
166
    }
167

168
    /**
169
     * @throws GeometryException
170
     */
171
    private function readFeatureCollection(stdClass $geoJsonFeatureCollection): FeatureCollection
172
    {
173
        $this->verifyType($geoJsonFeatureCollection, 'FeatureCollection');
48✔
174

175
        if (! property_exists($geoJsonFeatureCollection, 'features')) {
48✔
176
            throw GeometryIoException::invalidGeoJson('Missing "FeatureCollection.features" attribute.');
×
177
        }
178

179
        if (! is_array($geoJsonFeatureCollection->features)) {
48✔
180
            throw GeometryIoException::invalidGeoJson('Malformed "FeatureCollection.features" attribute.');
×
181
        }
182

183
        $features = [];
48✔
184

185
        foreach ($geoJsonFeatureCollection->features as $feature) {
48✔
186
            if (! is_object($feature)) {
48✔
187
                throw GeometryIoException::invalidGeoJson(sprintf(
×
188
                    'Unexpected data of type %s in "FeatureCollection.features" attribute.',
×
189
                    get_debug_type($features),
×
190
                ));
×
191
            }
192

193
            /** @var stdClass $feature */
194
            $features[] = $this->readFeature($feature);
48✔
195
        }
196

197
        return new FeatureCollection(...$features);
48✔
198
    }
199

200
    /**
201
     * @throws GeometryException
202
     */
203
    private function readGeometry(stdClass $geoJsonGeometry): Geometry
204
    {
205
        if (! isset($geoJsonGeometry->type) || ! is_string($geoJsonGeometry->type)) {
1,232✔
206
            throw GeometryIoException::invalidGeoJson('Missing or malformed "Geometry.type" attribute.');
×
207
        }
208

209
        $geoType = $this->normalizeGeoJsonType($geoJsonGeometry->type);
1,232✔
210

211
        if ($geoType === 'GeometryCollection') {
1,232✔
212
            return $this->readGeometryCollection($geoJsonGeometry);
232✔
213
        }
214

215
        if (! isset($geoJsonGeometry->coordinates) || ! is_array($geoJsonGeometry->coordinates)) {
1,176✔
216
            throw GeometryIoException::invalidGeoJson(sprintf('Missing or malformed "%s.coordinates" attribute.', $geoType));
×
217
        }
218

219
        /*
220
         * TODO: we should actually check the contents of the coords array here!
221
         *       Type-hints make static analysis happy, but errors will appear at runtime if the GeoJSON is invalid.
222
         */
223

224
        $coordinates = $geoJsonGeometry->coordinates;
1,176✔
225

226
        $hasZ = $this->hasZ($coordinates);
1,176✔
227
        $hasM = false;
1,176✔
228
        $srid = 4326;
1,176✔
229

230
        $cs = new CoordinateSystem($hasZ, $hasM, $srid);
1,176✔
231

232
        switch ($geoType) {
233
            case 'Point':
1,176✔
234
                /** @var list<float> $coordinates */
235
                return $this->genPoint($cs, $coordinates);
384✔
236

237
            case 'LineString':
960✔
238
                /** @var list<list<float>> $coordinates */
239
                return $this->genLineString($cs, $coordinates);
240✔
240

241
            case 'Polygon':
720✔
242
                /** @var list<list<list<float>>> $coordinates */
243
                return $this->genPolygon($cs, $coordinates);
240✔
244

245
            case 'MultiPoint':
480✔
246
                /** @var list<list<float>> $coordinates */
247
                return $this->genMultiPoint($cs, $coordinates);
168✔
248

249
            case 'MultiLineString':
312✔
250
                /** @var list<list<list<float>>> $coordinates */
251
                return $this->genMultiLineString($cs, $coordinates);
144✔
252

253
            case 'MultiPolygon':
168✔
254
                /** @var list<list<list<list<float>>>> $coordinates */
255
                return $this->genMultiPolygon($cs, $coordinates);
168✔
256
        }
257

258
        throw GeometryIoException::unsupportedGeoJsonType($geoType);
×
259
    }
260

261
    /**
262
     * @throws GeometryException
263
     */
264
    private function readGeometryCollection(stdClass $jsonGeometryCollection): GeometryCollection
265
    {
266
        $this->verifyType($jsonGeometryCollection, 'GeometryCollection');
232✔
267

268
        if (! isset($jsonGeometryCollection->geometries)) {
232✔
269
            throw GeometryIoException::invalidGeoJson('Missing "GeometryCollection.geometries" attribute.');
×
270
        }
271

272
        if (! is_array($jsonGeometryCollection->geometries)) {
232✔
273
            throw GeometryIoException::invalidGeoJson('Malformed "GeometryCollection.geometries" attribute.');
×
274
        }
275

276
        $geometries = [];
232✔
277

278
        foreach ($jsonGeometryCollection->geometries as $geometry) {
232✔
279
            if (! is_object($geometry)) {
184✔
280
                throw GeometryIoException::invalidGeoJson(sprintf(
×
281
                    'Unexpected data of type %s in "GeometryCollection.geometries" attribute.',
×
282
                    get_debug_type($geometry),
×
283
                ));
×
284
            }
285

286
            if (isset($geometry->type) && $geometry->type === 'GeometryCollection' && ! $this->lenient) {
184✔
287
                throw GeometryIoException::invalidGeoJson(
8✔
288
                    'GeoJSON does not allow nested GeometryCollections. ' .
8✔
289
                    'You can allow this by setting the $lenient flag to true.',
8✔
290
                );
8✔
291
            }
292

293
            /** @var stdClass $geometry */
294
            $geometries[] = $this->readGeometry($geometry);
176✔
295
        }
296

297
        if (! $geometries) {
224✔
298
            return new GeometryCollection(CoordinateSystem::xy(4326));
48✔
299
        }
300

301
        return GeometryCollection::of(...$geometries);
176✔
302
    }
303

304
    /**
305
     * [x, y]
306
     *
307
     * @param list<float> $coords
308
     *
309
     * @throws GeometryException
310
     */
311
    private function genPoint(CoordinateSystem $cs, array $coords): Point
312
    {
313
        return new Point($cs, ...$coords);
936✔
314
    }
315

316
    /**
317
     * [[x, y], ...]
318
     *
319
     * @param list<list<float>> $coords
320
     *
321
     * @throws GeometryException
322
     */
323
    private function genMultiPoint(CoordinateSystem $cs, array $coords): MultiPoint
324
    {
325
        $points = [];
168✔
326

327
        foreach ($coords as $pointCoords) {
168✔
328
            $points[] = $this->genPoint($cs, $pointCoords);
120✔
329
        }
330

331
        return new MultiPoint($cs, ...$points);
168✔
332
    }
333

334
    /**
335
     * [[x, y], ...]
336
     *
337
     * @param list<list<float>> $coords
338
     *
339
     * @throws GeometryException
340
     */
341
    private function genLineString(CoordinateSystem $cs, array $coords): LineString
342
    {
343
        $points = [];
648✔
344

345
        foreach ($coords as $pointCoords) {
648✔
346
            $points[] = $this->genPoint($cs, $pointCoords);
600✔
347
        }
348

349
        return new LineString($cs, ...$points);
648✔
350
    }
351

352
    /**
353
     * [[[x, y], ...], ...]
354
     *
355
     * @param list<list<list<float>>> $coords
356
     *
357
     * @throws GeometryException
358
     */
359
    private function genMultiLineString(CoordinateSystem $cs, array $coords): MultiLineString
360
    {
361
        $lineStrings = [];
144✔
362

363
        foreach ($coords as $lineStringCoords) {
144✔
364
            $lineStrings[] = $this->genLineString($cs, $lineStringCoords);
96✔
365
        }
366

367
        return new MultiLineString($cs, ...$lineStrings);
144✔
368
    }
369

370
    /**
371
     * [[[x, y], ...], ...]
372
     *
373
     * @param list<list<list<float>>> $coords
374
     *
375
     * @throws GeometryException
376
     */
377
    private function genPolygon(CoordinateSystem $cs, array $coords): Polygon
378
    {
379
        $lineStrings = [];
360✔
380

381
        foreach ($coords as $lineStringCoords) {
360✔
382
            $lineStrings[] = $this->genLineString($cs, $lineStringCoords);
312✔
383
        }
384

385
        return new Polygon($cs, ...$lineStrings);
360✔
386
    }
387

388
    /**
389
     * [[[[x, y], ...], ...], ...]
390
     *
391
     * @param list<list<list<list<float>>>> $coords
392
     *
393
     * @throws GeometryException
394
     */
395
    private function genMultiPolygon(CoordinateSystem $cs, array $coords): MultiPolygon
396
    {
397
        $polygons = [];
168✔
398

399
        foreach ($coords as $polygonCoords) {
168✔
400
            $polygons[] = $this->genPolygon($cs, $polygonCoords);
120✔
401
        }
402

403
        return new MultiPolygon($cs, ...$polygons);
168✔
404
    }
405

406
    /**
407
     * @param array $coords A potentially nested list of floats.
408
     *
409
     * @psalm-suppress MixedAssignment
410
     * @psalm-suppress MixedArgument
411
     */
412
    private function hasZ(array $coords): bool
413
    {
414
        if (empty($coords)) {
1,176✔
415
            return false;
288✔
416
        }
417

418
        // At least one Geometry hasZ
419
        if (! is_array($coords[0])) {
888✔
420
            return 3 === count($coords);
888✔
421
        }
422

423
        foreach ($coords as $coord) {
720✔
424
            if ($this->hasZ($coord)) {
720✔
425
                return true;
336✔
426
            }
427
        }
428

429
        return false;
384✔
430
    }
431

432
    /**
433
     * @throws GeometryIoException
434
     */
435
    private function verifyType(stdClass $geoJsonObject, string $type): void
436
    {
437
        if (isset($geoJsonObject->type) && is_string($geoJsonObject->type)) {
816✔
438
            if ($this->normalizeGeoJsonType($geoJsonObject->type) === $type) {
816✔
439
                return;
816✔
440
            }
441
        }
442

443
        throw GeometryIoException::invalidGeoJson(sprintf('Missing or malformed "%s.type" attribute.', $type));
×
444
    }
445

446
    /**
447
     * Normalizes the given GeoJSON type.
448
     *
449
     * If the type is not recognized, it is returned as is.
450
     * If the type is recognized but in the wrong case, it is fixed in lenient mode,
451
     * and an exception is thrown otherwise.
452
     */
453
    private function normalizeGeoJsonType(string $type): string
454
    {
455
        $typeLower = strtolower($type);
1,544✔
456

457
        if (isset(self::TYPES[$typeLower])) {
1,544✔
458
            $correctCase = self::TYPES[$typeLower];
1,544✔
459

460
            if ($type === $correctCase) {
1,544✔
461
                return $type;
880✔
462
            }
463

464
            if ($this->lenient) {
664✔
465
                return $correctCase;
416✔
466
            }
467

468
            throw GeometryIoException::unsupportedGeoJsonTypeWrongCase($type, $correctCase);
248✔
469
        }
470

471
        return $type;
×
472
    }
473
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc