1<?php
2
3/**
4 * This file contains the BinaryReader class.
5 * For more information see the class description below.
6 *
7 * @author Peter Bathory <peter.bathory@cartographia.hu>
8 * @since 2016-02-18
9 *
10 * This code is open-source and licenced under the Modified BSD License.
11 * For the full copyright and license information, please view the LICENSE
12 * file that was distributed with this source code.
13 */
14
15namespace geoPHP\Adapter;
16
17use geoPHP\Geometry\Collection;
18use geoPHP\Geometry\Geometry;
19use geoPHP\Geometry\GeometryCollection;
20use geoPHP\Geometry\LineString;
21use geoPHP\Geometry\MultiGeometry;
22use geoPHP\Geometry\MultiLineString;
23use geoPHP\Geometry\MultiPoint;
24use geoPHP\Geometry\MultiPolygon;
25use geoPHP\Geometry\Point;
26use geoPHP\Geometry\Polygon;
27
28/**
29 * PHP Geometry <-> TWKB encoder/decoder
30 *
31 * "Tiny Well-known Binary is is a multi-purpose format for serializing vector geometry data into a byte buffer,
32 * with an emphasis on minimizing size of the buffer."
33 * @see https://github.com/TWKB/Specification/blob/master/twkb.md
34 *
35 * This implementation supports:
36 * - reading and writing all geometry types (1-7)
37 * - empty geometries
38 * - extended precision (Z, M coordinates; custom precision)
39 * Partially supports:
40 * - bounding box: can read and write, but don't store readed boxes (API missing)
41 * - size attribute: can read and write size attribute, but seeking is not supported
42 * - ID list: can read and write, but API is completely missing
43 */
44class TWKB implements GeoAdapter
45{
46
47    protected $writeOptions = [
48            'decimalDigitsXY' => 5,
49            'decimalDigitsZ' =>  0,
50            'decimalDigitsM' =>  0,
51            'includeSize' => false,
52            'includeBoundingBoxes' => false,
53    ];
54
55    /** @var Point|null  */
56    private $lastPoint = null;
57
58    /** @var  BinaryReader $reader */
59    private $reader;
60
61    /** @var  BinaryWriter $writer */
62    private $writer;
63
64    /** @var array Maps Geometry types to TWKB type codes */
65    protected static $typeMap = [
66            Geometry::POINT               => 1,
67            Geometry::LINE_STRING         => 2,
68            Geometry::POLYGON             => 3,
69            Geometry::MULTI_POINT         => 4,
70            Geometry::MULTI_LINE_STRING   => 5,
71            Geometry::MULTI_POLYGON       => 6,
72            Geometry::GEOMETRY_COLLECTION => 7
73    ];
74
75    /**
76     * Read TWKB into geometry objects
77     *
78     * @param string $twkb        Tiny Well-known-binary string
79     * @param bool   $isHexString If this is a hexadecimal string that is in need of packing
80     *
81     * @return Geometry
82     *
83     * @throws \Exception
84     */
85    public function read($twkb, $isHexString = false)
86    {
87        if ($isHexString) {
88            $twkb = @pack('H*', $twkb);
89        }
90
91        if (empty($twkb)) {
92            throw new \Exception('Cannot read empty TWKB. Found ' . gettype($twkb));
93        }
94
95        $this->reader = new BinaryReader($twkb);
96
97        $geometry = $this->getGeometry();
98
99        $this->reader->close();
100
101        return $geometry;
102    }
103
104    protected function getGeometry()
105    {
106        $options = [];
107        $type = $this->reader->readUInt8();
108        $metadataHeader = $this->reader->readUInt8();
109
110        $geometryType = $type & 0x0F;
111        $options['precision'] = BinaryReader::zigZagDecode($type >> 4);
112        $options['precisionFactor'] = pow(10, $options['precision']);
113
114        $options['hasBoundingBox'] = ($metadataHeader >> 0 & 1) == 1;
115        $options['hasSizeAttribute'] = ($metadataHeader >> 1 & 1) == 1;
116        $options['hasIdList'] = ($metadataHeader >> 2 & 1) == 1;
117        $options['hasExtendedPrecision'] = ($metadataHeader >> 3 & 1) == 1;
118        $options['isEmpty'] = ($metadataHeader >> 4 & 1) == 1;
119        $options['unused1'] = ($metadataHeader >> 5 & 1) == 1;
120        $options['unused2'] = ($metadataHeader >> 6 & 1) == 1;
121        $options['unused3'] = ($metadataHeader >> 7 & 1) == 1;
122
123        if ($options['hasExtendedPrecision']) {
124            $extendedPrecision = $this->reader->readUInt8();
125
126            $options['hasZ'] = ($extendedPrecision & 0x01) === 0x01;
127            $options['hasM'] = ($extendedPrecision & 0x02) === 0x02;
128
129            $options['zPrecision'] = ($extendedPrecision & 0x1C) >> 2;
130            $options['zPrecisionFactor'] = pow(10, $options['zPrecision']);
131
132            $options['mPrecision'] = ($extendedPrecision & 0xE0) >> 5;
133            $options['mPrecisionFactor'] = pow(10, $options['mPrecision']);
134        } else {
135            $options['hasZ'] = false;
136            $options['hasM'] = false;
137        }
138        if ($options['hasSizeAttribute']) {
139            $options['remainderSize'] = $this->reader->readUVarInt();
140        }
141        if ($options['hasBoundingBox']) {
142            $dimension = 2 + ($options['hasZ'] ? 1 : 0) + ($options['hasM'] ? 1 : 0);
143            $precisions = [
144                $options['precisionFactor'],
145                $options['precisionFactor'],
146                $options['hasZ'] ? $options['zPrecisionFactor'] : 0,
147                $options['hasM'] ? $options['mPrecisionFactor'] : 0
148            ];
149            $bBoxMin = $bBoxMax = [];
150            for ($i = 0; $i < $dimension; $i++) {
151                $bBoxMin[$i] = $this->reader->readUVarInt() / $precisions[$i];
152                $bBoxMax[$i] = $this->reader->readUVarInt() / $precisions[$i] + $bBoxMin[$i];
153            }
154            /** @noinspection PhpUndefinedVariableInspection (minimum 2 dimension) */
155            $options['boundingBox'] = ['minXYZM' => $bBoxMin, 'maxXYZM' => $bBoxMax];
156        }
157
158        if ($options['unused1']) {
159            $this->reader->readUVarInt();
160        }
161        if ($options['unused2']) {
162            $this->reader->readUVarInt();
163        }
164        if ($options['unused3']) {
165            $this->reader->readUVarInt();
166        }
167
168        $this->lastPoint = new Point(0, 0, 0, 0);
169
170        switch ($geometryType) {
171            case 1:
172                $geometry = $this->getPoint($options);
173                break;
174            case 2:
175                $geometry = $this->getLineString($options);
176                break;
177            case 3:
178                $geometry = $this->getPolygon($options);
179                break;
180            case 4:
181                $geometry = $this->getMulti('Point', $options);
182                break;
183            case 5:
184                $geometry = $this->getMulti('LineString', $options);
185                break;
186            case 6:
187                $geometry = $this->getMulti('Polygon', $options);
188                break;
189            case 7:
190                $geometry = $this->getMulti('Geometry', $options);
191                break;
192            default:
193                throw new \Exception(
194                    'Geometry type ' . $geometryType .
195                        ' (' . (array_search($geometryType, self::$typeMap) ?: 'unknown') . ') not supported'
196                );
197        }
198
199        return $geometry;
200    }
201
202    /**
203     * @param array $options
204     *
205     * @return Point
206     * @throws \Exception
207     */
208    protected function getPoint($options)
209    {
210        if ($options['isEmpty']) {
211            return new Point();
212        }
213        $x = round(
214            $this->lastPoint->x() + $this->reader->readSVarInt() / $options['precisionFactor'],
215            $options['precision']
216        );
217        $y = round(
218            $this->lastPoint->y() + $this->reader->readSVarInt() / $options['precisionFactor'],
219            $options['precision']
220        );
221        $z = $options['hasZ'] ? round(
222            $this->lastPoint->z() + $this->reader->readSVarInt() / $options['zPrecisionFactor'],
223            $options['zPrecision']
224        ) : null;
225        $m = $options['hasM'] ? round(
226            $this->lastPoint->m() + $this->reader->readSVarInt() / $options['mPrecisionFactor'],
227            $options['mPrecision']
228        ) : null;
229
230        $this->lastPoint = new Point($x, $y, $z, $m);
231        return $this->lastPoint;
232    }
233
234    /**
235     * @param array $options
236     *
237     * @return LineString
238     * @throws \Exception
239     */
240    protected function getLineString($options)
241    {
242        if ($options['isEmpty']) {
243            return new LineString();
244        }
245
246        $pointCount = $this->reader->readUVarInt();
247
248        $points = [];
249        for ($i = 0; $i < $pointCount; $i++) {
250            $points[] = $this->getPoint($options);
251        }
252
253        return new LineString($points);
254    }
255
256    /**
257     * @param array $options
258     *
259     * @return Polygon
260     * @throws \Exception
261     */
262    protected function getPolygon($options)
263    {
264        if ($options['isEmpty']) {
265            return new Polygon();
266        }
267
268        $ringCount = $this->reader->readUVarInt();
269
270        $rings = [];
271        for ($i = 0; $i < $ringCount; $i++) {
272            $rings[] = $this->getLineString($options);
273        }
274
275        return new Polygon($rings, true);
276    }
277
278    /**
279     * @param string $type
280     * @param array $options
281     *
282     * @return MultiGeometry|null
283     * @throws \Exception
284     */
285    protected function getMulti($type, $options)
286    {
287        $multiLength = $this->reader->readUVarInt();
288
289        if ($options['hasIdList']) {
290            for ($i = 0; $i < $multiLength; $i++) {
291                $idList[] = $this->reader->readSVarInt();
292            }
293        }
294
295        $components = [];
296        for ($i = 0; $i < $multiLength; $i++) {
297            if ($type !== 'Geometry') {
298                $func = 'get' . $type;
299                $components[] = $this->$func($options);
300            } else {
301                $components[] = $this->getGeometry();
302            }
303        }
304        switch ($type) {
305            case 'Point':
306                return new MultiPoint($components);
307            case 'LineString':
308                return new MultiLineString($components);
309            case 'Polygon':
310                return new MultiPolygon($components);
311            case 'Geometry':
312                return new GeometryCollection($components);
313        }
314        return null;
315    }
316
317
318/******* WRITER *******/
319
320    /**
321     * Serialize geometries into TWKB string.
322     *
323     * @return string The WKB string representation of the input geometries
324     * @param Geometry $geometry The geometry
325     * @param bool|true $writeAsHex Write the result in binary or hexadecimal system
326     * @param null $decimalDigitsXY Coordinate precision of X and Y. Default is 5 decimals
327     * @param null $decimalDigitsZ Coordinate precision of Z. Default is 0 decimal
328     * @param null $decimalDigitsM Coordinate precision of M. Default is 0 decimal
329     * @param bool $includeSizes Includes the size in bytes of the remainder of the geometry after the size attribute. Default is false
330     * @param bool $includeBoundingBoxes Includes the coordinates of bounding box' two corner. Default is false
331     *
332     * @return string binary or hexadecimal representation of TWKB
333     */
334    public function write(Geometry $geometry, $writeAsHex = false, $decimalDigitsXY = null, $decimalDigitsZ = null, $decimalDigitsM = null, $includeSizes = false, $includeBoundingBoxes = false)
335    {
336        $this->writer = new BinaryWriter();
337
338        $this->writeOptions = [
339                'decimalDigitsXY' => $decimalDigitsXY !== null ? $decimalDigitsXY : $this->writeOptions['decimalDigitsXY'],
340                'decimalDigitsZ' => $decimalDigitsZ !== null ? $decimalDigitsZ : $this->writeOptions['decimalDigitsZ'],
341                'decimalDigitsM' => $decimalDigitsM !== null ? $decimalDigitsM : $this->writeOptions['decimalDigitsM'],
342                'includeSize' => $includeSizes ? true : $this->writeOptions['includeSize'],
343                'includeBoundingBoxes' => $includeBoundingBoxes ? true : $this->writeOptions['includeBoundingBoxes']
344        ];
345        $this->writeOptions = array_merge(
346            $this->writeOptions,
347            [
348                'xyFactor' => pow(10, $this->writeOptions['decimalDigitsXY']),
349                'zFactor' => pow(10, $this->writeOptions['decimalDigitsZ']),
350                'mFactor' => pow(10, $this->writeOptions['decimalDigitsM'])
351            ]
352        );
353
354        $twkb = $this->writeGeometry($geometry);
355
356        return $writeAsHex ? current(unpack('H*', $twkb)) : $twkb;
357    }
358
359    /**
360     * @param Geometry $geometry
361     * @return string
362     */
363    protected function writeGeometry($geometry)
364    {
365        $this->writeOptions['hasZ'] = $geometry->hasZ();
366        $this->writeOptions['hasM'] = $geometry->isMeasured();
367
368        // Type and precision
369        $type = self::$typeMap[$geometry->geometryType()] +
370                (BinaryWriter::zigZagEncode($this->writeOptions['decimalDigitsXY']) << 4);
371        $twkbHead = $this->writer->writeUInt8($type);
372
373        // Is there extended precision information?
374        $metadataHeader = $this->writeOptions['includeBoundingBoxes'] << 0;
375        // Is there extended precision information?
376        $metadataHeader += $this->writeOptions['includeSize'] << 1;
377        // Is there an ID list?
378        // TODO: implement this (needs metadata support in geoPHP)
379        //$metadataHeader += $this->writeOptions['hasIdList'] << 2;
380        // Is there extended precision information?
381        $metadataHeader += ($geometry->hasZ() || $geometry->isMeasured()) << 3;
382        // Is this an empty geometry?
383        $metadataHeader += $geometry->isEmpty() << 4;
384
385        $twkbHead .= $this->writer->writeUInt8($metadataHeader);
386
387        $twkbGeom = '';
388        if (!$geometry->isEmpty()) {
389            $this->lastPoint = new Point(0, 0, 0, 0);
390
391            switch ($geometry->geometryType()) {
392                case Geometry::POINT:
393                    /** @var Point $geometry */
394                    $twkbGeom .= $this->writePoint($geometry);
395                    break;
396                case Geometry::LINE_STRING:
397                    /** @var LineString $geometry */
398                    $twkbGeom .= $this->writeLineString($geometry);
399                    break;
400                case Geometry::POLYGON:
401                    /** @var Polygon $geometry */
402                    $twkbGeom .= $this->writePolygon($geometry);
403                    break;
404                case Geometry::MULTI_POINT:
405                case Geometry::MULTI_LINE_STRING:
406                case Geometry::MULTI_POLYGON:
407                case Geometry::GEOMETRY_COLLECTION:
408                    /** @var Collection $geometry */
409                    $twkbGeom .= $this->writeMulti($geometry);
410                    break;
411            }
412        }
413
414        if ($this->writeOptions['includeBoundingBoxes']) {
415            $bBox = $geometry->getBoundingBox();
416            // X
417            $twkbBox = $this->writer->writeSVarInt($bBox['minx'] * $this->writeOptions['xyFactor']);
418            $twkbBox .= $this->writer->writeSVarInt(($bBox['maxx'] - $bBox['minx']) * $this->writeOptions['xyFactor']);
419            // Y
420            $twkbBox .= $this->writer->writeSVarInt($bBox['miny'] * $this->writeOptions['xyFactor']);
421            $twkbBox .= $this->writer->writeSVarInt(($bBox['maxy'] - $bBox['miny']) * $this->writeOptions['xyFactor']);
422            if ($geometry->hasZ()) {
423                $bBox['minz'] = $geometry->minimumZ();
424                $bBox['maxz'] = $geometry->maximumZ();
425                $twkbBox .= $this->writer->writeSVarInt(round($bBox['minz'] * $this->writeOptions['zFactor']));
426                $twkbBox .= $this->writer->writeSVarInt(round(($bBox['maxz'] - $bBox['minz']) * $this->writeOptions['zFactor']));
427            }
428            if ($geometry->isMeasured()) {
429                $bBox['minm'] = $geometry->minimumM();
430                $bBox['maxm'] = $geometry->maximumM();
431                $twkbBox .= $this->writer->writeSVarInt($bBox['minm'] * $this->writeOptions['mFactor']);
432                $twkbBox .= $this->writer->writeSVarInt(($bBox['maxm'] - $bBox['minm']) * $this->writeOptions['mFactor']);
433            }
434            $twkbGeom = $twkbBox . $twkbGeom;
435        }
436
437        if ($geometry->hasZ() || $geometry->isMeasured()) {
438            $extendedPrecision = 0;
439            if ($geometry->hasZ()) {
440                $extendedPrecision |= ($geometry->hasZ() ? 0x1 : 0) | ($this->writeOptions['decimalDigitsZ'] << 2);
441            }
442            if ($geometry->isMeasured()) {
443                $extendedPrecision |= ($geometry->isMeasured() ? 0x2 : 0) | ($this->writeOptions['decimalDigitsM'] << 5);
444            }
445            $twkbHead .= $this->writer->writeUInt8($extendedPrecision);
446        }
447        if ($this->writeOptions['includeSize']) {
448            $twkbHead .= $this->writer->writeUVarInt(strlen($twkbGeom));
449        }
450
451        return $twkbHead . $twkbGeom;
452    }
453
454    /**
455     * @param Point $geometry
456     * @return string
457     */
458    protected function writePoint($geometry)
459    {
460        $x = round($geometry->x() * $this->writeOptions['xyFactor']);
461        $y = round($geometry->y() * $this->writeOptions['xyFactor']);
462        $z = round($geometry->z() * $this->writeOptions['zFactor']);
463        $m = round($geometry->m() * $this->writeOptions['mFactor']);
464
465        $twkb = $this->writer->writeSVarInt($x - $this->lastPoint->x());
466        $twkb .= $this->writer->writeSVarInt($y - $this->lastPoint->y());
467        if ($this->writeOptions['hasZ']) {
468            $twkb .= $this->writer->writeSVarInt($z - $this->lastPoint->z());
469        }
470        if ($this->writeOptions['hasM']) {
471            $twkb .= $this->writer->writeSVarInt($m - $this->lastPoint->m());
472        }
473
474        $this->lastPoint = new Point($x, $y, $this->writeOptions['hasZ'] ? $z : null, $this->writeOptions['hasM'] ? $m : null);
475
476        return $twkb;
477    }
478
479    /**
480     * @param LineString $geometry
481     * @return string
482     */
483    protected function writeLineString($geometry)
484    {
485        $twkb = $this->writer->writeUVarInt($geometry->numPoints());
486        foreach ($geometry->getComponents() as $component) {
487            $twkb .= $this->writePoint($component);
488        }
489        return $twkb;
490    }
491
492    /**
493     * @param Polygon $geometry
494     * @return string
495     */
496    protected function writePolygon($geometry)
497    {
498        $twkb = $this->writer->writeUVarInt($geometry->numGeometries());
499        foreach ($geometry->getComponents() as $component) {
500            $twkb .= $this->writeLineString($component);
501        }
502        return $twkb;
503    }
504
505    /**
506     * @param Collection $geometry
507     * @return string
508     */
509    protected function writeMulti($geometry)
510    {
511        $twkb = $this->writer->writeUVarInt($geometry->numGeometries());
512        //if ($geometry->hasIdList()) {
513        //  foreach ($geometry->getComponents() as $component) {
514        //      $this->writer->writeUVarInt($component->getId());
515        //  }
516        //}
517        foreach ($geometry->getComponents() as $component) {
518            if ($geometry->geometryType() !== Geometry::GEOMETRY_COLLECTION) {
519                $func = 'write' . $component->geometryType();
520                $twkb .= $this->$func($component);
521            } else {
522                $twkb .= $this->writeGeometry($component);
523            }
524        }
525        return $twkb;
526    }
527}
528