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

brick / geo / 13632627167

03 Mar 2025 01:58PM UTC coverage: 47.859% (+0.3%) from 47.546%
13632627167

push

github

BenMorel
Final methods in abstract test classes

1632 of 3410 relevant lines covered (47.86%)

965.73 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
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
     * @psalm-param list<float> $coords
296
     *
297
     * @param float[] $coords
298
     *
299
     * @throws GeometryException
300
     */
301
    private function genPoint(CoordinateSystem $cs, array $coords) : Point
302
    {
303
        return new Point($cs, ...$coords);
819✔
304
    }
305

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

319
        foreach ($coords as $pointCoords) {
147✔
320
            $points[] = $this->genPoint($cs, $pointCoords);
105✔
321
        }
322

323
        return new MultiPoint($cs, ...$points);
147✔
324
    }
325

326
    /**
327
     * [[x, y], ...]
328
     *
329
     * @psalm-param list<list<float>> $coords
330
     *
331
     * @param float[][] $coords
332
     *
333
     * @throws GeometryException
334
     */
335
    private function genLineString(CoordinateSystem $cs, array $coords) : LineString
336
    {
337
        $points = [];
567✔
338

339
        foreach ($coords as $pointCoords) {
567✔
340
            $points[] = $this->genPoint($cs, $pointCoords);
525✔
341
        }
342

343
        return new LineString($cs, ...$points);
567✔
344
    }
345

346
    /**
347
     * [[[x, y], ...], ...]
348
     *
349
     * @psalm-param list<list<list<float>>> $coords
350
     *
351
     * @param float[][][] $coords
352
     *
353
     * @throws GeometryException
354
     */
355
    private function genMultiLineString(CoordinateSystem $cs, array $coords) : MultiLineString
356
    {
357
        $lineStrings = [];
126✔
358

359
        foreach ($coords as $lineStringCoords) {
126✔
360
            $lineStrings[] = $this->genLineString($cs, $lineStringCoords);
84✔
361
        }
362

363
        return new MultiLineString($cs, ...$lineStrings);
126✔
364
    }
365

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

379
        foreach ($coords as $lineStringCoords) {
315✔
380
            $lineStrings[] = $this->genLineString($cs, $lineStringCoords);
273✔
381
        }
382

383
        return new Polygon($cs, ...$lineStrings);
315✔
384
    }
385

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

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

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

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

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

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

429
        return false;
336✔
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)){
714✔
438
            if ($this->normalizeGeoJSONType($geoJsonObject->type) === $type) {
714✔
439
                return;
714✔
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,351✔
456

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

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

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

468
            throw GeometryIOException::unsupportedGeoJSONTypeWrongCase($type, $correctCase);
217✔
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