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

brick / geo / 13753277563

09 Mar 2025 10:43PM UTC coverage: 49.787% (+2.5%) from 47.295%
13753277563

push

github

BenMorel
Prepare for release

1749 of 3513 relevant lines covered (49.79%)

975.53 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
/**
24
 * Builds geometries out of GeoJSON text strings.
25
 */
26
final class GeoJSONReader
27
{
28
    /**
29
     * The GeoJSON types, in their correct case according to the standard, indexed by their lowercase counterpart.
30
     */
31
    private const TYPES = [
32
        'feature'            => 'Feature',
33
        'featurecollection'  => 'FeatureCollection',
34
        'point'              => 'Point',
35
        'linestring'         => 'LineString',
36
        'polygon'            => 'Polygon',
37
        'multipoint'         => 'MultiPoint',
38
        'multilinestring'    => 'MultiLineString',
39
        'multipolygon'       => 'MultiPolygon',
40
        'geometrycollection' => 'GeometryCollection',
41
    ];
42

43
    private readonly bool $lenient;
44

45
    /**
46
     * @param bool $lenient Whether to parse the GeoJSON in lenient mode.
47
     *                      This mode allows for some deviations from the GeoJSON spec:
48
     *                        - wrong case for GeoJSON types, e.g. POINT instead of Point
49
     *                        - missing "geometry" or "properties" attributes in Features
50
 *                            - nested GeometryCollections
51
     */
52
    public function __construct(bool $lenient = false)
53
    {
54
        $this->lenient = $lenient;
1,351✔
55
    }
56

57
    /**
58
     * @throws GeometryException If the GeoJSON file is invalid.
59
     */
60
    public function read(string $geoJson): Geometry|Feature|FeatureCollection
61
    {
62
        try {
63
            $geoJsonObject = json_decode($geoJson, flags: JSON_THROW_ON_ERROR);
1,351✔
64
        } catch (JsonException $e) {
×
65
            throw GeometryIOException::invalidGeoJSON('Unable to parse GeoJSON string.', $e);
×
66
        }
67

68
        if (! is_object($geoJsonObject)) {
1,351✔
69
            throw GeometryIOException::invalidGeoJSON('GeoJSON string does not represent an object.');
×
70
        }
71

72
        /** @var stdClass $geoJsonObject */
73
        return $this->readAsObject($geoJsonObject);
1,351✔
74
    }
75

76
    /**
77
     * @throws GeometryException
78
     */
79
    private function readAsObject(stdClass $geoJsonObject): Geometry|Feature|FeatureCollection
80
    {
81
        if (! isset($geoJsonObject->type) || ! is_string($geoJsonObject->type)) {
1,351✔
82
            throw GeometryIOException::invalidGeoJSON('Missing or malformed "type" attribute.');
×
83
        }
84

85
        $geoType = $this->normalizeGeoJSONType($geoJsonObject->type);
1,351✔
86

87
        switch ($geoType) {
88
            case 'Feature':
1,134✔
89
                return $this->readFeature($geoJsonObject);
532✔
90

91
            case 'FeatureCollection':
602✔
92
                return $this->readFeatureCollection($geoJsonObject);
42✔
93

94
            case 'Point':
560✔
95
            case 'LineString':
497✔
96
            case 'Polygon':
434✔
97
            case 'MultiPoint':
329✔
98
            case 'MultiLineString':
266✔
99
            case 'MultiPolygon':
203✔
100
            case 'GeometryCollection':
140✔
101
                return $this->readGeometry($geoJsonObject);
560✔
102

103
            default:
104
                throw GeometryIOException::unsupportedGeoJSONType($geoJsonObject->type);
×
105
        }
106
    }
107

108
    /**
109
     * @throws GeometryException
110
     */
111
    private function readFeature(stdClass $geoJsonFeature) : Feature
112
    {
113
        $this->verifyType($geoJsonFeature, 'Feature');
574✔
114

115
        $geometry = null;
574✔
116

117
        if (property_exists($geoJsonFeature, 'geometry')) {
574✔
118
            if ($geoJsonFeature->geometry !== null) {
560✔
119
                if (! is_object($geoJsonFeature->geometry)) {
518✔
120
                    throw GeometryIOException::invalidGeoJSON('Malformed "Feature.geometry" attribute.');
×
121
                }
122

123
                /** @var stdClass $geoJsonFeature->geometry */
124
                $geometry = $this->readGeometry($geoJsonFeature->geometry);
548✔
125
            }
126
        } elseif (! $this->lenient) {
14✔
127
            throw GeometryIOException::invalidGeoJSON(
7✔
128
                'Missing "Feature.geometry" attribute. ' .
7✔
129
                'Features without geometry should use an explicit null value for this field. ' .
7✔
130
                'You can ignore this error by setting the $lenient flag to true.',
7✔
131
            );
7✔
132
        }
133

134
        $properties = null;
567✔
135

136
        if (property_exists($geoJsonFeature, 'properties')) {
567✔
137
            if ($geoJsonFeature->properties !== null) {
553✔
138
                if (! is_object($geoJsonFeature->properties)) {
490✔
139
                    throw GeometryIOException::invalidGeoJSON('Malformed "Feature.properties" attribute.');
×
140
                }
141

142
                /** @var stdClass $properties */
143
                $properties = $geoJsonFeature->properties;
535✔
144
            }
145
        } elseif (! $this->lenient) {
14✔
146
            throw GeometryIOException::invalidGeoJSON(
7✔
147
                'Missing "Feature.properties" attribute. ' .
7✔
148
                'Features without properties should use an explicit null value for this field. ' .
7✔
149
                'You can ignore this error by setting the $lenient flag to true.',
7✔
150
            );
7✔
151
        }
152

153
        return new Feature($geometry, $properties);
560✔
154
    }
155

156
    /**
157
     * @throws GeometryException
158
     */
159
    private function readFeatureCollection(stdClass $geoJsonFeatureCollection) : FeatureCollection
160
    {
161
        $this->verifyType($geoJsonFeatureCollection, 'FeatureCollection');
42✔
162

163
        if (! property_exists($geoJsonFeatureCollection, 'features')) {
42✔
164
            throw GeometryIOException::invalidGeoJSON('Missing "FeatureCollection.features" attribute.');
×
165
        }
166

167
        if (! is_array($geoJsonFeatureCollection->features)) {
42✔
168
            throw GeometryIOException::invalidGeoJSON('Malformed "FeatureCollection.features" attribute.');
×
169
        }
170

171
        $features = [];
42✔
172

173
        foreach ($geoJsonFeatureCollection->features as $feature) {
42✔
174
            if (! is_object($feature)) {
42✔
175
                throw GeometryIOException::invalidGeoJSON(sprintf(
×
176
                    'Unexpected data of type %s in "FeatureCollection.features" attribute.',
×
177
                    get_debug_type($features)
×
178
                ));
×
179
            }
180

181
            /** @var stdClass $feature */
182
            $features[] = $this->readFeature($feature);
42✔
183
        }
184

185
        return new FeatureCollection(...$features);
42✔
186
    }
187

188
    /**
189
     * @throws GeometryException
190
     */
191
    private function readGeometry(stdClass $geoJsonGeometry) : Geometry
192
    {
193
        if (! isset($geoJsonGeometry->type) || ! is_string($geoJsonGeometry->type)) {
1,078✔
194
            throw GeometryIOException::invalidGeoJSON('Missing or malformed "Geometry.type" attribute.');
×
195
        }
196

197
        $geoType = $this->normalizeGeoJSONType($geoJsonGeometry->type);
1,078✔
198

199
        if ($geoType === 'GeometryCollection') {
1,078✔
200
            return $this->readGeometryCollection($geoJsonGeometry);
203✔
201
        }
202

203
        if (! isset($geoJsonGeometry->coordinates) || ! is_array($geoJsonGeometry->coordinates)) {
1,029✔
204
            throw GeometryIOException::invalidGeoJSON(sprintf('Missing or malformed "%s.coordinates" attribute.', $geoType));
×
205
        }
206

207
        /*
208
         * TODO: we should actually check the contents of the coords array here!
209
         *       Type-hints make static analysis happy, but errors will appear at runtime if the GeoJSON is invalid.
210
         */
211

212
        $coordinates = $geoJsonGeometry->coordinates;
1,029✔
213

214
        $hasZ = $this->hasZ($coordinates);
1,029✔
215
        $hasM = false;
1,029✔
216
        $srid = 4326;
1,029✔
217

218
        $cs = new CoordinateSystem($hasZ, $hasM, $srid);
1,029✔
219

220
        switch ($geoType) {
221
            case 'Point':
1,029✔
222
                /** @var list<float> $coordinates */
223
                return $this->genPoint($cs, $coordinates);
336✔
224

225
            case 'LineString':
840✔
226
                /** @var list<list<float>> $coordinates */
227
                return $this->genLineString($cs, $coordinates);
210✔
228

229
            case 'Polygon':
630✔
230
                /** @var list<list<list<float>>> $coordinates */
231
                return $this->genPolygon($cs, $coordinates);
210✔
232

233
            case 'MultiPoint':
420✔
234
                /** @var list<list<float>> $coordinates */
235
                return $this->genMultiPoint($cs, $coordinates);
147✔
236

237
            case 'MultiLineString':
273✔
238
                /** @var list<list<list<float>>> $coordinates */
239
                return $this->genMultiLineString($cs, $coordinates);
126✔
240

241
            case 'MultiPolygon':
147✔
242
                /** @var list<list<list<list<float>>>> $coordinates */
243
                return $this->genMultiPolygon($cs, $coordinates);
147✔
244
        }
245

246
        throw GeometryIOException::unsupportedGeoJSONType($geoType);
×
247
    }
248

249
    /**
250
     * @throws GeometryException
251
     */
252
    private function readGeometryCollection(stdClass $jsonGeometryCollection): GeometryCollection
253
    {
254
        $this->verifyType($jsonGeometryCollection, 'GeometryCollection');
203✔
255

256
        if (! isset($jsonGeometryCollection->geometries)) {
203✔
257
            throw GeometryIOException::invalidGeoJSON('Missing "GeometryCollection.geometries" attribute.');
×
258
        }
259

260
        if (! is_array($jsonGeometryCollection->geometries)) {
203✔
261
            throw GeometryIOException::invalidGeoJSON('Malformed "GeometryCollection.geometries" attribute.');
×
262
        }
263

264
        $geometries = [];
203✔
265

266
        foreach ($jsonGeometryCollection->geometries as $geometry) {
203✔
267
            if (! is_object($geometry)) {
161✔
268
                throw GeometryIOException::invalidGeoJSON(sprintf(
×
269
                    'Unexpected data of type %s in "GeometryCollection.geometries" attribute.',
×
270
                    get_debug_type($geometry)
×
271
                ));
×
272
            }
273

274
            if (isset($geometry->type) && $geometry->type === 'GeometryCollection' && ! $this->lenient) {
161✔
275
                throw GeometryIOException::invalidGeoJSON(
7✔
276
                    'GeoJSON does not allow nested GeometryCollections. ' .
7✔
277
                    'You can allow this by setting the $lenient flag to true.',
7✔
278
                );
7✔
279
            }
280

281
            /** @var stdClass $geometry */
282
            $geometries[] = $this->readGeometry($geometry);
154✔
283
        }
284

285
        if (! $geometries) {
196✔
286
            return new GeometryCollection(CoordinateSystem::xy(4326));
42✔
287
        }
288

289
        return GeometryCollection::of(...$geometries);
154✔
290
    }
291

292
    /**
293
     * [x, y]
294
     *
295
     * @param list<float> $coords
296
     *
297
     * @throws GeometryException
298
     */
299
    private function genPoint(CoordinateSystem $cs, array $coords) : Point
300
    {
301
        return new Point($cs, ...$coords);
819✔
302
    }
303

304
    /**
305
     * [[x, y], ...]
306
     *
307
     * @param list<list<float>> $coords
308
     *
309
     * @throws GeometryException
310
     */
311
    private function genMultiPoint(CoordinateSystem $cs, array $coords) : MultiPoint
312
    {
313
        $points = [];
147✔
314

315
        foreach ($coords as $pointCoords) {
147✔
316
            $points[] = $this->genPoint($cs, $pointCoords);
105✔
317
        }
318

319
        return new MultiPoint($cs, ...$points);
147✔
320
    }
321

322
    /**
323
     * [[x, y], ...]
324
     *
325
     * @param list<list<float>> $coords
326
     *
327
     * @throws GeometryException
328
     */
329
    private function genLineString(CoordinateSystem $cs, array $coords) : LineString
330
    {
331
        $points = [];
567✔
332

333
        foreach ($coords as $pointCoords) {
567✔
334
            $points[] = $this->genPoint($cs, $pointCoords);
525✔
335
        }
336

337
        return new LineString($cs, ...$points);
567✔
338
    }
339

340
    /**
341
     * [[[x, y], ...], ...]
342
     *
343
     * @param list<list<list<float>>> $coords
344
     *
345
     * @throws GeometryException
346
     */
347
    private function genMultiLineString(CoordinateSystem $cs, array $coords) : MultiLineString
348
    {
349
        $lineStrings = [];
126✔
350

351
        foreach ($coords as $lineStringCoords) {
126✔
352
            $lineStrings[] = $this->genLineString($cs, $lineStringCoords);
84✔
353
        }
354

355
        return new MultiLineString($cs, ...$lineStrings);
126✔
356
    }
357

358
    /**
359
     * [[[x, y], ...], ...]
360
     *
361
     * @param list<list<list<float>>> $coords
362
     *
363
     * @throws GeometryException
364
     */
365
    private function genPolygon(CoordinateSystem $cs, array $coords) : Polygon
366
    {
367
        $lineStrings = [];
315✔
368

369
        foreach ($coords as $lineStringCoords) {
315✔
370
            $lineStrings[] = $this->genLineString($cs, $lineStringCoords);
273✔
371
        }
372

373
        return new Polygon($cs, ...$lineStrings);
315✔
374
    }
375

376
    /**
377
     * [[[[x, y], ...], ...], ...]
378
     *
379
     * @param list<list<list<list<float>>>> $coords
380
     *
381
     * @throws GeometryException
382
     */
383
    private function genMultiPolygon(CoordinateSystem $cs, array $coords) : MultiPolygon
384
    {
385
        $polygons = [];
147✔
386

387
        foreach ($coords as $polygonCoords) {
147✔
388
            $polygons[] = $this->genPolygon($cs, $polygonCoords);
105✔
389
        }
390

391
        return new MultiPolygon($cs, ...$polygons);
147✔
392
    }
393

394
    /**
395
     * @psalm-suppress MixedAssignment
396
     * @psalm-suppress MixedArgument
397
     *
398
     * @param array $coords A potentially nested list of floats.
399
     */
400
    private function hasZ(array $coords) : bool
401
    {
402
        if (empty($coords)) {
1,029✔
403
            return false;
252✔
404
        }
405

406
        // At least one Geometry hasZ
407
        if (! is_array($coords[0])) {
777✔
408
            return 3 === count($coords);
777✔
409
        }
410

411
        foreach ($coords as $coord) {
630✔
412
            if ($this->hasZ($coord)) {
630✔
413
                return true;
294✔
414
            }
415
        }
416

417
        return false;
336✔
418
    }
419

420
    /**
421
     * @throws GeometryIOException
422
     */
423
    private function verifyType(stdClass $geoJsonObject, string $type): void
424
    {
425
        if (isset($geoJsonObject->type) && is_string($geoJsonObject->type)){
714✔
426
            if ($this->normalizeGeoJSONType($geoJsonObject->type) === $type) {
714✔
427
                return;
714✔
428
            }
429
        }
430

431
        throw GeometryIOException::invalidGeoJSON(sprintf('Missing or malformed "%s.type" attribute.', $type));
×
432
    }
433

434
    /**
435
     * Normalizes the given GeoJSON type.
436
     *
437
     * If the type is not recognized, it is returned as is.
438
     * If the type is recognized but in the wrong case, it is fixed in lenient mode,
439
     * and an exception is thrown otherwise.
440
     */
441
    private function normalizeGeoJSONType(string $type) : string
442
    {
443
        $typeLower = strtolower($type);
1,351✔
444

445
        if (isset(self::TYPES[$typeLower])) {
1,351✔
446
            $correctCase = self::TYPES[$typeLower];
1,351✔
447

448
            if ($type === $correctCase) {
1,351✔
449
                return $type;
770✔
450
            }
451

452
            if ($this->lenient) {
581✔
453
                return $correctCase;
364✔
454
            }
455

456
            throw GeometryIOException::unsupportedGeoJSONTypeWrongCase($type, $correctCase);
217✔
457
        }
458

459
        return $type;
×
460
    }
461
}
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