1<?php
2
3/*
4 * This file is part of the GeoPHP package.
5 * Copyright (c) 2011 - 2016 Patrick Hayes and contributors
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace geoPHP;
11
12use geoPHP\Adapter\GeoHash;
13use geoPHP\Geometry\Collection;
14use geoPHP\Geometry\Geometry;
15use geoPHP\Geometry\GeometryCollection;
16
17// @codingStandardsIgnoreLine
18class geoPHP
19{
20    // Earth radius constants in meters
21
22    /** WGS84 semi-major axis (a), aka equatorial radius */
23    const EARTH_WGS84_SEMI_MAJOR_AXIS = 6378137.0;
24    /** WGS84 semi-minor axis (b), aka polar radius */
25    const EARTH_WGS84_SEMI_MINOR_AXIS = 6356752.314245;
26    /** WGS84 inverse flattening */
27    const EARTH_WGS84_FLATTENING      = 298.257223563;
28
29    /** WGS84 semi-major axis (a), aka equatorial radius */
30    const EARTH_GRS80_SEMI_MAJOR_AXIS = 6378137.0;
31    /** GRS80 semi-minor axis */
32    const EARTH_GRS80_SEMI_MINOR_AXIS = 6356752.314140;
33    /** GRS80 inverse flattening */
34    const EARTH_GRS80_FLATTENING      = 298.257222100882711;
35
36    /** IUGG mean radius R1 = (2a + b) / 3 */
37    const EARTH_MEAN_RADIUS           = 6371008.8;
38    /** IUGG R2: Earth's authalic ("equal area") radius is the radius of a hypothetical perfect sphere
39     * which has the same surface area as the reference ellipsoid. */
40    const EARTH_AUTHALIC_RADIUS       = 6371007.2;
41
42    const CLASS_NAMESPACE = 'geoPHP\\';
43
44    private static $adapterMap = [
45            'wkt'            => 'WKT',
46            'ewkt'           => 'EWKT',
47            'wkb'            => 'WKB',
48            'ewkb'           => 'EWKB',
49            'json'           => 'GeoJSON',
50            'geojson'        => 'GeoJSON',
51            'kml'            => 'KML',
52            'gpx'            => 'GPX',
53            'georss'         => 'GeoRSS',
54            'google_geocode' => 'GoogleGeocode',
55            'geohash'        => 'GeoHash',
56            'twkb'           => 'TWKB',
57            'osm'            => 'OSM',
58    ];
59
60    public static function getAdapterMap()
61    {
62        return self::$adapterMap;
63    }
64
65    private static $geometryList = [
66            'point'              => 'Point',
67            'linestring'         => 'LineString',
68            'polygon'            => 'Polygon',
69            'multipoint'         => 'MultiPoint',
70            'multilinestring'    => 'MultiLineString',
71            'multipolygon'       => 'MultiPolygon',
72            'geometrycollection' => 'GeometryCollection',
73    ];
74
75    public static function getGeometryList()
76    {
77        return self::$geometryList;
78    }
79
80    /**
81     * Converts data to Geometry using geo adapters
82     *
83     * If $data is an array, all passed in values will be combined into a single geometry
84     *
85     * @param mixed $data The data in any supported format, including geoPHP Geometry
86     * @var null|string $type Data type. Tries to detect if omitted
87     * @var mixed|null $otherArgs Arguments will be passed to the geo adapter
88     *
89     * @return Collection|Geometry
90     * @throws \Exception
91     */
92    public static function load($data)
93    {
94        $args = func_get_args();
95
96        $data = array_shift($args);
97        $type = count($args) && @array_key_exists($args[0], self::$adapterMap) ? strtolower(array_shift($args)) : null;
98
99        // Auto-detect type if needed
100        if (!$type) {
101            // If the user is trying to load a Geometry from a Geometry... Just pass it back
102            if (is_object($data)) {
103                if ($data instanceof Geometry) {
104                    return $data;
105                }
106            }
107
108            $detected = geoPHP::detectFormat($data);
109            if (!$detected) {
110                throw new \Exception("Can not detect format");
111            }
112            $format = explode(':', $detected);
113            $type = array_shift($format);
114            $args = $format ?: $args;
115        }
116
117        if (!array_key_exists($type, self::$adapterMap)) {
118            throw new \Exception('geoPHP could not find an adapter of type ' . htmlentities($type));
119        }
120        $adapterType = self::CLASS_NAMESPACE . 'Adapter\\' . self::$adapterMap[$type];
121
122        $adapter = new $adapterType();
123
124        // Data is not an array, just pass it normally
125        if (!is_array($data)) {
126            $result = call_user_func_array([$adapter, "read"], array_merge([$data], $args));
127        } else { // Data is an array, combine all passed in items into a single geometry
128            $geometries = [];
129            foreach ($data as $item) {
130                $geometries[] = call_user_func_array([$adapter, "read"], array_merge($item, $args));
131            }
132            $result = geoPHP::buildGeometry($geometries);
133        }
134
135        return $result;
136    }
137
138    public static function geosInstalled($force = null)
139    {
140        static $geosInstalled = null;
141        if ($force !== null) {
142            $geosInstalled = $force;
143        }
144        if (getenv('GEOS_DISABLED') == 1) {
145            $geosInstalled = false;
146        }
147        if ($geosInstalled !== null) {
148            return $geosInstalled;
149        }
150        $geosInstalled = class_exists('GEOSGeometry', false);
151
152        return $geosInstalled;
153    }
154
155    /**
156     * @param \GEOSGeometry $geos
157     * @return Geometry|null
158     * @throws \Exception
159     * @codeCoverageIgnore
160     */
161    public static function geosToGeometry($geos)
162    {
163        if (!geoPHP::geosInstalled()) {
164            return null;
165        }
166        /** @noinspection PhpUndefinedClassInspection */
167        $wkbWriter = new \GEOSWKBWriter();
168        /** @noinspection PhpUndefinedMethodInspection */
169        $wkb = $wkbWriter->writeHEX($geos);
170        $geometry = geoPHP::load($wkb, 'wkb', true);
171        if ($geometry) {
172            $geometry->setGeos($geos);
173            return $geometry;
174        }
175
176        return null;
177    }
178
179    /**
180     * Reduce a geometry, or an array of geometries, into their 'lowest' available common geometry.
181     * For example a GeometryCollection of only points will become a MultiPoint
182     * A multi-point containing a single point will return a point.
183     * An array of geometries can be passed and they will be compiled into a single geometry
184     *
185     * @param Geometry|Geometry[]|GeometryCollection|GeometryCollection[] $geometries
186     * @return Geometry|false
187     */
188    public static function geometryReduce($geometries)
189    {
190        if (empty($geometries)) {
191            return false;
192        }
193        /*
194         * If it is a single geometry
195         */
196        if ($geometries instanceof Geometry) {
197            // If the geometry cannot even theoretically be reduced more, then pass it back
198            $singleGeometries = ['Point', 'LineString', 'Polygon'];
199            if (in_array($geometries->geometryType(), $singleGeometries)) {
200                return $geometries;
201            }
202
203            // If it is a multi-geometry, check to see if it just has one member
204            // If it does, then pass the member, if not, then just pass back the geometry
205            if (strpos($geometries->geometryType(), 'Multi') === 0) {
206                $components = $geometries->getComponents();
207                if (count($components) == 1) {
208                    return $components[0];
209                } else {
210                    return $geometries;
211                }
212            }
213        } elseif (is_array($geometries) && count($geometries) == 1) {
214            // If it's an array of one, then just parse the one
215            return geoPHP::geometryReduce(array_shift($geometries));
216        }
217
218        if (!is_array($geometries)) {
219            $geometries = [$geometries];
220        }
221        /**
222         * So now we either have an array of geometries
223         * @var Geometry[]|GeometryCollection[] $geometries
224         */
225
226        $reducedGeometries = [];
227        $geometryTypes = [];
228        self::explodeCollections($geometries, $reducedGeometries, $geometryTypes);
229
230        $geometryTypes = array_unique($geometryTypes);
231        if (empty($geometryTypes)) {
232            return false;
233        }
234        if (count($geometryTypes) == 1) {
235            if (count($reducedGeometries) == 1) {
236                return $reducedGeometries[0];
237            } else {
238                $class = self::CLASS_NAMESPACE .
239                    'Geometry\\' .
240                    (strstr($geometryTypes[0], 'Multi') ? '' : 'Multi') .
241                    $geometryTypes[0];
242                return new $class($reducedGeometries);
243            }
244        } else {
245            return new GeometryCollection($reducedGeometries);
246        }
247    }
248
249    /**
250     * @param Geometry[]|GeometryCollection[] $unreduced
251     */
252    private static function explodeCollections($unreduced, &$reduced, &$types)
253    {
254        foreach ($unreduced as $item) {
255            if ($item->geometryType() == 'GeometryCollection' || strpos($item->geometryType(), 'Multi') === 0) {
256                self::explodeCollections($item->getComponents(), $reduced, $types);
257            } else {
258                $reduced[] = $item;
259                $types[] = $item->geometryType();
260            }
261        }
262    }
263
264    /**
265     * Build an appropriate Geometry, MultiGeometry, or GeometryCollection to contain the Geometries in it.
266     *
267     * @see geos::geom::GeometryFactory::buildGeometry
268     *
269     * @param Geometry|Geometry[]|GeometryCollection|GeometryCollection[] $geometries
270     * @return Geometry A Geometry of the "smallest", "most type-specific" class that can contain the elements.
271     * @throws \Exception
272     */
273    public static function buildGeometry($geometries)
274    {
275        if (empty($geometries)) {
276            return new GeometryCollection();
277        }
278
279        /* If it is a single geometry */
280        if ($geometries instanceof Geometry) {
281            return $geometries;
282        } elseif (!is_array($geometries)) {
283            return null;
284            //FIXME should be: throw new \Exception('Input is not a Geometry or array of Geometries');
285        } elseif (count($geometries) == 1) {
286            // If it's an array of one, then just parse the one
287            return geoPHP::buildGeometry(array_shift($geometries));
288        }
289
290        /**
291         * So now we either have an array of geometries
292         * @var Geometry[]|GeometryCollection[] $geometries
293         */
294
295        $geometryTypes = [];
296        $hasData = false;
297        foreach ($geometries as $item) {
298            if ($item) {
299                $geometryTypes[] = $item->geometryType();
300                if ($item->getData() !== null) {
301                    $hasData = true;
302                }
303            }
304        }
305        $geometryTypes = array_unique($geometryTypes);
306        if (empty($geometryTypes)) {
307            return null;
308            // FIXME normally it never happens. Should be refactored
309        }
310        if (count($geometryTypes) == 1 && !$hasData) {
311            if ($geometryTypes[0] === Geometry::GEOMETRY_COLLECTION) {
312                return new GeometryCollection($geometries);
313            }
314            if (count($geometries) == 1) {
315                return $geometries[0];
316            } else {
317                $newType = (strpos($geometryTypes[0], 'Multi') !== false ? '' : 'Multi') . $geometryTypes[0];
318                foreach ($geometries as $geometry) {
319                    if ($geometry->isEmpty()) {
320                        return new GeometryCollection($geometries);
321                    }
322                }
323                $class = self::CLASS_NAMESPACE . 'Geometry\\' . $newType;
324                return new $class($geometries);
325            }
326        } else {
327            return new GeometryCollection($geometries);
328        }
329    }
330
331    /**
332     * Detect a format given a value. This function is meant to be SPEEDY.
333     * It could make a mistake in XML detection if you are mixing or using namespaces in weird ways
334     * (ie, KML inside an RSS feed)
335     *
336     * @param mixed $input
337     *
338     * @return string|false
339     */
340    public static function detectFormat(&$input)
341    {
342        $input = (string) $input;
343        $mem = fopen('php://memory', 'x+');
344        fwrite($mem, $input, 11); // Write 11 bytes - we can detect the vast majority of formats in the first 11 bytes
345        fseek($mem, 0);
346
347        $bin = fread($mem, 11);
348        $bytes = unpack("c*", $bin);
349
350        // If bytes is empty, then we were passed empty input
351        if (empty($bytes)) {
352            return false;
353        }
354
355        // First char is a tab, space or carriage-return. trim it and try again
356        if ($bytes[1] == 9 || $bytes[1] == 10 || $bytes[1] == 32) {
357            $input = ltrim($input);
358            return geoPHP::detectFormat($input);
359        }
360
361        // Detect WKB or EWKB -- first byte is 1 (little endian indicator)
362        if ($bytes[1] == 1 || $bytes[1] == 0) {
363            $wkbType = current(unpack($bytes[1] == 1 ? 'V' : 'N', substr($bin, 1, 4)));
364            if (array_search($wkbType & 0xF, Adapter\WKB::$typeMap)) {
365                // If SRID byte is TRUE (1), it's EWKB
366                if (($wkbType & Adapter\WKB::SRID_MASK) === Adapter\WKB::SRID_MASK) {
367                    return 'ewkb';
368                } else {
369                    return 'wkb';
370                }
371            }
372        }
373
374        // Detect HEX encoded WKB or EWKB (PostGIS format) -- first byte is 48, second byte is 49 (hex '01' => first-byte = 1)
375        // The shortest possible WKB string (LINESTRING EMPTY) is 18 hex-chars (9 encoded bytes) long
376        // This differentiates it from a geohash, which is always shorter than 13 characters.
377        if ($bytes[1] == 48 && ($bytes[2] == 49 || $bytes[2] == 48) && strlen($input) > 12) {
378            if ((current(unpack($bytes[2] == 49 ? 'V' : 'N', hex2bin(substr($bin, 2, 8)))) & Adapter\WKB::SRID_MASK) == Adapter\WKB::SRID_MASK) {
379                return 'ewkb:true';
380            } else {
381                return 'wkb:true';
382            }
383        }
384
385        // Detect GeoJSON - first char starts with {
386        if ($bytes[1] == 123) {
387            return 'json';
388        }
389
390        // Detect EWKT - strats with "SRID=number;"
391        if (substr($input, 0, 5) === 'SRID=') {
392            return 'ewkt';
393        }
394
395        // Detect WKT - starts with a geometry type name
396        if (Adapter\WKT::isWktType(strstr($input, ' ', true))) {
397            return 'wkt';
398        }
399
400        // Detect XML -- first char is <
401        if ($bytes[1] == 60) {
402            // grab the first 1024 characters
403            $string = substr($input, 0, 1024);
404            if (strpos($string, '<kml') !== false) {
405                return 'kml';
406            }
407            if (strpos($string, '<coordinate') !== false) {
408                return 'kml';
409            }
410            if (strpos($string, '<gpx') !== false) {
411                return 'gpx';
412            }
413            if (strpos($string, '<osm ') !== false) {
414                return 'osm';
415            }
416            if (preg_match('/<[a-z]{3,20}>/', $string) !== false) {
417                return 'georss';
418            }
419        }
420
421        // We need an 8 byte string for geohash and unpacked WKB / WKT
422        fseek($mem, 0);
423        $string = trim(fread($mem, 8));
424
425        // Detect geohash - geohash ONLY contains lowercase chars and numerics
426        preg_match('/[' . GeoHash::$characterTable . ']+/', $string, $matches);
427        if (isset($matches[0]) && $matches[0] == $string && strlen($input) <= 13) {
428            return 'geohash';
429        }
430
431        preg_match('/^[a-f0-9]+$/', $string, $matches);
432        if (isset($matches[0])) {
433            return 'twkb:true';
434        } else {
435            return 'twkb';
436        }
437
438        // What do you get when you cross an elephant with a rhino?
439        // http://youtu.be/RCBn5J83Poc
440    }
441}
442