From 8fadecc6b622c61b5865922062d7eadb11e83e88 Mon Sep 17 00:00:00 2001 From: Andy Street Date: Tue, 8 Jan 2019 18:22:22 +0000 Subject: [PATCH] Rewrite GPXReader class Rewrite using XSD schemas to improve preformance, validation and code readability. --- docs/samples/full-1.1.gpx | 200 +++---- src/libgpx/gpx-1-0.xsd | 227 +++++++ src/libgpx/gpx-1-1.xsd | 784 ++++++++++++++++++++++++ src/libgpx/gpxreader.php | 1178 +++++++++++++++++-------------------- src/libgpx/peekstream.php | 195 ++++++ 5 files changed, 1840 insertions(+), 744 deletions(-) create mode 100644 src/libgpx/gpx-1-0.xsd create mode 100644 src/libgpx/gpx-1-1.xsd create mode 100644 src/libgpx/peekstream.php diff --git a/docs/samples/full-1.1.gpx b/docs/samples/full-1.1.gpx index a061ed8..f240cf7 100644 --- a/docs/samples/full-1.1.gpx +++ b/docs/samples/full-1.1.gpx @@ -1,101 +1,101 @@ - + - - full-1.1 - This is a sample file containing all parts of the GPX 1.1 specification supported by libgpx. - - John Doe - - - John Doe's Homepage - text/html - - - - 2018 - https://creativecommons.org/publicdomain/zero/1.0/ - - - Example Inc. - text/html - - - Example, GPX - - - Jane Doe - - - - 10.3 - - 12.3 - 10 - Horse Sands Fort - Fort - A fort in the Solent - Guess - - More Information - text/html - - Flag, Blue - Nautical - 3d - 6 - 1 - 2 - 3 - 86400 - 13 - - Jane Doe - - - - Sample Route - Sample - A Sample route - Imagination - - Sample Route - text/html - - 1 - A type - - Jane Doe - - - - - - Sample Track - track - A sample track - Imagination - - Sample Route - text/html - - 1 - A type - - Jane Doe - - - - Jane Doe - - - - - - - - - - - Jane Doe - - + + full-1.1 + This is a sample file containing all parts of the GPX 1.1 specification supported by libgpx. + + John Doe + + + John Doe's Homepage + text/html + + + + 2018 + https://creativecommons.org/publicdomain/zero/1.0/ + + + Example Inc. + text/html + + + Example, GPX + + + Jane Doe + + + + 10.3 + + 12.3 + 10 + Horse Sands Fort + Fort + A fort in the Solent + Guess + + More Information + text/html + + Flag, Blue + Nautical + 3d + 6 + 1 + 2 + 3 + 86400 + 13 + + Jane Doe + + + + Sample Route + Sample + A Sample route + Imagination + + Sample Route + text/html + + 1 + A type + + Jane Doe + + + + + + Sample Track + track + A sample track + Imagination + + Sample Route + text/html + + 1 + A type + + Jane Doe + + + + + + Jane Doe + + + + + + + + + Jane Doe + + diff --git a/src/libgpx/gpx-1-0.xsd b/src/libgpx/gpx-1-0.xsd new file mode 100644 index 0000000..93c530f --- /dev/null +++ b/src/libgpx/gpx-1-0.xsd @@ -0,0 +1,227 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/libgpx/gpx-1-1.xsd b/src/libgpx/gpx-1-1.xsd new file mode 100644 index 0000000..6ffd92d --- /dev/null +++ b/src/libgpx/gpx-1-1.xsd @@ -0,0 +1,784 @@ + + + + + + GPX schema version 1.1 - For more information on GPX and this schema, visit http://www.topografix.com/gpx.asp + + GPX uses the following conventions: all coordinates are relative to the WGS84 datum. All measurements are in metric units. + + + + + + + GPX is the root element in the XML file. + + + + + + + + GPX documents contain a metadata header, followed by waypoints, routes, and tracks. You can add your own elements + to the extensions section of the GPX document. + + + + + + + Metadata about the file. + + + + + + + A list of waypoints. + + + + + + + A list of routes. + + + + + + + A list of tracks. + + + + + + + You can add extend GPX by adding your own elements from another schema here. + + + + + + + + + You must include the version number in your GPX document. + + + + + + + You must include the name or URL of the software that created your GPX document. This allows others to + inform the creator of a GPX instance document that fails to validate. + + + + + + + + + Information about the GPX file, author, and copyright restrictions goes in the metadata section. Providing rich, + meaningful information about your GPX files allows others to search for and use your GPS data. + + + + + + + The name of the GPX file. + + + + + + + A description of the contents of the GPX file. + + + + + + + The person or organization who created the GPX file. + + + + + + + Copyright and license information governing use of the file. + + + + + + + URLs associated with the location described in the file. + + + + + + + The creation date of the file. + + + + + + + Keywords associated with the file. Search engines or databases can use this information to classify the data. + + + + + + + Minimum and maximum coordinates which describe the extent of the coordinates in the file. + + + + + + + + You can add extend GPX by adding your own elements from another schema here. + + + + + + + + + + wpt represents a waypoint, point of interest, or named feature on a map. + + + + + + + + Elevation (in meters) of the point. + + + + + + + Creation/modification timestamp for element. Date and time in are in Univeral Coordinated Time (UTC), not local time! Conforms to ISO 8601 specification for date/time representation. Fractional seconds are allowed for millisecond timing in tracklogs. + + + + + + + Magnetic variation (in degrees) at the point + + + + + + + Height (in meters) of geoid (mean sea level) above WGS84 earth ellipsoid. As defined in NMEA GGA message. + + + + + + + + + The GPS name of the waypoint. This field will be transferred to and from the GPS. GPX does not place restrictions on the length of this field or the characters contained in it. It is up to the receiving application to validate the field before sending it to the GPS. + + + + + + + GPS waypoint comment. Sent to GPS as comment. + + + + + + + A text description of the element. Holds additional information about the element intended for the user, not the GPS. + + + + + + + Source of data. Included to give user some idea of reliability and accuracy of data. "Garmin eTrex", "USGS quad Boston North", e.g. + + + + + + + Link to additional information about the waypoint. + + + + + + + Text of GPS symbol name. For interchange with other programs, use the exact spelling of the symbol as displayed on the GPS. If the GPS abbreviates words, spell them out. + + + + + + + Type (classification) of the waypoint. + + + + + + + + + Type of GPX fix. + + + + + + + Number of satellites used to calculate the GPX fix. + + + + + + + Horizontal dilution of precision. + + + + + + + Vertical dilution of precision. + + + + + + + Position dilution of precision. + + + + + + + Number of seconds since last DGPS update. + + + + + + + ID of DGPS station used in differential correction. + + + + + + + + You can add extend GPX by adding your own elements from another schema here. + + + + + + + + + The latitude of the point. This is always in decimal degrees, and always in WGS84 datum. + + + + + + + The longitude of the point. This is always in decimal degrees, and always in WGS84 datum. + + + + + + + + + rte represents route - an ordered list of waypoints representing a series of turn points leading to a destination. + + + + + + + GPS name of route. + + + + + + + GPS comment for route. + + + + + + + Text description of route for user. Not sent to GPS. + + + + + + + Source of data. Included to give user some idea of reliability and accuracy of data. + + + + + + + Links to external information about the route. + + + + + + + GPS route number. + + + + + + + Type (classification) of route. + + + + + + + + You can add extend GPX by adding your own elements from another schema here. + + + + + + + + A list of route points. + + + + + + + + + + trk represents a track - an ordered list of points describing a path. + + + + + + + GPS name of track. + + + + + + + GPS comment for track. + + + + + + + User description of track. + + + + + + + Source of data. Included to give user some idea of reliability and accuracy of data. + + + + + + + Links to external information about track. + + + + + + + GPS track number. + + + + + + + Type (classification) of track. + + + + + + + + You can add extend GPX by adding your own elements from another schema here. + + + + + + + + A Track Segment holds a list of Track Points which are logically connected in order. To represent a single GPS track where GPS reception was lost, or the GPS receiver was turned off, start a new Track Segment for each continuous span of track data. + + + + + + + + + + You can add extend GPX by adding your own elements from another schema here. + + + + + + + You can add extend GPX by adding your own elements from another schema here. + + + + + + + + + + A Track Segment holds a list of Track Points which are logically connected in order. To represent a single GPS track where GPS reception was lost, or the GPS receiver was turned off, start a new Track Segment for each continuous span of track data. + + + + + + + A Track Point holds the coordinates, elevation, timestamp, and metadata for a single point in a track. + + + + + + + + You can add extend GPX by adding your own elements from another schema here. + + + + + + + + + + Information about the copyright holder and any license governing use of this file. By linking to an appropriate license, + you may place your data into the public domain or grant additional usage rights. + + + + + + + Year of copyright. + + + + + + + Link to external file containing license text. + + + + + + + + Copyright holder (TopoSoft, Inc.) + + + + + + + + + A link to an external resource (Web page, digital photo, video clip, etc) with additional information. + + + + + + + Text of hyperlink. + + + + + + + Mime type of content (image/jpeg) + + + + + + + + URL of hyperlink. + + + + + + + + + An email address. Broken into two parts (id and domain) to help prevent email harvesting. + + + + + + id half of email address (billgates2004) + + + + + + + domain half of email address (hotmail.com) + + + + + + + + + A person or organization. + + + + + + + Name of person or organization. + + + + + + + Email address. + + + + + + + Link to Web site or other external information about person. + + + + + + + + + + A geographic point with optional elevation and time. Available for use by other schemas. + + + + + + + The elevation (in meters) of the point. + + + + + + + The time that the point was recorded. + + + + + + + + The latitude of the point. Decimal degrees, WGS84 datum. + + + + + + + The latitude of the point. Decimal degrees, WGS84 datum. + + + + + + + + + An ordered sequence of points. (for polygons or polylines, e.g.) + + + + + + + Ordered list of geographic points. + + + + + + + + + + Two lat/lon pairs defining the extent of an element. + + + + + + The minimum latitude. + + + + + + + The minimum longitude. + + + + + + + The maximum latitude. + + + + + + + The maximum longitude. + + + + + + + + + + The latitude of the point. Decimal degrees, WGS84 datum. + + + + + + + + + + + + The longitude of the point. Decimal degrees, WGS84 datum. + + + + + + + + + + + + Used for bearing, heading, course. Units are decimal degrees, true (not magnetic). + + + + + + + + + + + + Type of GPS fix. none means GPS had no fix. To signify "the fix info is unknown, leave out fixType entirely. pps = military signal used + + + + + + + + + + + + + + + Represents a differential GPS station. + + + + + + + + + \ No newline at end of file diff --git a/src/libgpx/gpxreader.php b/src/libgpx/gpxreader.php index 8e9c615..ba068b0 100644 --- a/src/libgpx/gpxreader.php +++ b/src/libgpx/gpxreader.php @@ -24,12 +24,8 @@ namespace libgpx; -use \DateTime; -use \DateTimeZone; -use \DomainException; -use \Exception; -use \InvalidArgumentException; -use \RuntimeException; +use \DateTimeImmutable; +use \XMLReader; /** * Read GPX files. @@ -40,45 +36,20 @@ class GPXReader { /** - * The namespace of the XML currently being parsed. - * - * @var string - */ - protected $ns; - - /** - * The GPX version of the XML currently being parsed. - * - * @var string + * Options used for XMLReader instances. */ - protected $version; - - /** - * The reader currently reading the XML. - * - * @var XMLReader - */ - protected $xml; + const XMLREADER_OPTIONS = LIBXML_NOERROR | LIBXML_NOWARNING; /** * Read GPX from a string. * - * @param string $xml The GPX XML data. + * @param string $data The GPX XML data. * @return GPX The GPX data. - * @throws RuntimeException If the XML could not be read. * @throws MalformedGPXException If the GPX file is invalid. */ - public function readString(string $xml) + public static function readString(string $data) { - try { - $this->xml = new XMLReader(); - if (!$this->xml->XML($xml, null, LIBXML_NOERROR | LIBXML_NOWARNING)) { - throw new RuntimeException('Unable to read XML from string.'); - } - return $this->read(); - } finally { - $this->xml = null; - } + return self::read($data, true); } /** @@ -88,12 +59,11 @@ class GPXReader * * @param string $filename The name of the file containing GPX XML data. * @return GPX The GPX data. - * @throws RuntimeException If the XML could not be read. * @throws MalformedGPXException If the GPX file is invalid. */ - public function readFile(string $filename) + public static function readFile(string $filename) { - return $this->readURI($filename); + return self::read($filename, false); } /** @@ -101,714 +71,634 @@ class GPXReader * * @param string $uri The location of the file containing GPX XML data. * @return GPX The GPX data. - * @throws RuntimeException If the XML could not be read. * @throws MalformedGPXException If the GPX file is invalid. */ - public function readURI(string $uri) + public static function readURI(string $uri) { - try { - $this->xml = new XMLReader(); - if (!$this->xml->open($uri, null, LIBXML_NOERROR | LIBXML_NOWARNING)) { - throw new RuntimeException(sprintf('Unable to read XML from: %s', $uri)); - } - return $this->read(); - } finally { - $this->xml = null; - } + return self::read($uri, false); } /** - * Read GPX data from XML. + * Read a GPX file. * - * @param XMLReader $xml Where to read the GPX data from. - * @return GPX The data that was read. - * @throws MalformedGPXException If the GPX file was invalid or not supported. + * @param string $src The source of the GPX data. + * @param boolean $fromString If true `src` is treated as a string containing + * the XML to parse otherwise it is treated as a URI to load data from. + * @return GPX The loaded GPX data. + * @throws MalformedGPXException If there was a problem parsing the XML. */ - protected function read() + public static function read(string $src, bool $fromString) { - $xml = $this->xml; + // Register stream wrapper + if (!$fromString) { + $wrappers = stream_get_wrappers(); + do { + $wrapper = uniqid('libgpx-'); + } while (in_array($wrapper, $wrappers)); + if (!stream_wrapper_register($wrapper, '\libgpx\PeekStream')) + throw new MalformedGPXException( + 'Unable to register stream.' + ); + } try { - // Fast forward to the root element. - while ($xml->nodeType !== XMLReader::ELEMENT) { - $xml->read(); + // Load data from file + $xml = new XMLReader(); + if ($fromString) { + if (!@$xml->XML($src, null, self::XMLREADER_OPTIONS)) + throw new MalformedGPXException( + 'Unable to read XML string.' + ); + } else { + if (!@$xml->open($wrapper . '://' . $src, null, self::XMLREADER_OPTIONS)) + throw new MalformedGPXException( + sprintf('Unable to read source “%s”', $src) + ); } - // Detect file version - switch ($xml->namespaceURI) { + // Namespace detection + $namespace = self::detectRootNamespace( + $fromString ? $src : PeekStream::peek($src) + ); + switch ($namespace) { case libgpx::NAMESPACE_GPX_1_0: - $this->version = '1.0'; - $this->ns = libgpx::NAMESPACE_GPX_1_0; + $xml->setSchema(__DIR__ . DIRECTORY_SEPARATOR . 'gpx-1-0.xsd'); break; case libgpx::NAMESPACE_GPX_1_1: - $this->version = '1.1'; - $this->ns = libgpx::NAMESPACE_GPX_1_1; + $xml->setSchema(__DIR__ . DIRECTORY_SEPARATOR . 'gpx-1-1.xsd'); break; - default: - throw new MalformedGPXException('Unknown or unsupported file format.'); + case null: + throw new MalformedGPXException( + 'Supplied XML is not namespaced.' + ); + default : + throw new MalformedGPXException( + sprintf('Unsupported namespace: %s', $namespace) + ); } - // Read GPX element. - if ($xml->localName == 'gpx') - return $this->readGPX(); - else - throw new MalformedGPXException('Root element must be "gpx".'); - + // Read XML + while ($valid = ($xml->read() && $xml->isValid())) { + if ($xml->nodeType == XMLReader::ELEMENT) break; + } + if (!$valid) + throw new MalformedGPXException(libxml_get_last_error()->message); + $gpx = self::readGPX($xml); } finally { - $this->version = null; - $this->ns = null; + if (!$fromString) stream_wrapper_unregister($wrapper); } + return $gpx; } /** - * Read a gpx type. + * Detect the namespace of the XML root element. * - * @return GPX The GPX data. - * @throws MalformedGPXException If the GPX file was invalid or not supported. + * @param string $data The XML to extract namespace from. + * @return string The namespace of the root element. + * @throws MalformedGPXException If it was not possible to detect the namespace. */ - protected function readGPX() - { - // Sanity check - ensure version attribute and schema declaration match. - if ($this->xml->getAttribute('version') != $this->version) - throw new MalformedGPXException( - 'GPX version attribute does not match namespace declaration.' - ); - - $result = new GPX(); - $result->setCreator($this->xml->getAttribute('creator')); - - $struct = [ - 'elements' => [ - 'wpt' => function ($gpx) { - $gpx->getWaypoints()[] = $this->readWpt(); - }, - 'rte' => function ($gpx) { - $gpx->getRoutes()[] = $this->readRte(); - }, - 'trk' => function ($gpx) { - $gpx->getTracks()[] = $this->readTrk(); - } - ] - ]; - if ($this->version == '1.1') { - $struct['elements']['metadata'] = function ($gpx) { - $this->readMetadata($gpx); - }; - $struct['elements']['extensions'] = function ($gpx) { - $this->readExtension($gpx->getExtensions()); - }; - } else { - $struct['elements']['name'] = function ($gpx) { - $gpx->setName($this->readXSDString()); - }; - $struct['elements']['desc'] = function ($gpx) { - $gpx->setDesc($this->readXSDString()); - }; - $struct['elements']['author'] = function ($gpx) { - $author = $gpx->getAuthor(); - if ($author === null) { - $author = new Person(); - $gpx->setAuthor($author); - } - $author->setName($this->readXSDString()); - }; - $struct['elements']['email'] = function ($gpx) { - $author = $gpx->getAuthor(); - if ($author === null) { - $author = new Person(); - $gpx->setAuthor($author); - } - $author->setEmail($this->readXSDString()); - }; - $struct['elements']['url'] = function ($gpx, &$state) { - $href = $this->readXSDString(); - $links = $gpx->getLinks(); - if ($links->isEmpty()) { - $link = new Link($href); - if (isset($state['urlname'])) { - $link->setText($state['urlname']); - unset($state['urlname']); - } - $links[] = $link; - } else { - $links->bottom()->setHref($href); - } - }; - $struct['elements']['urlname'] = function ($gpx, &$state) { - $text = $this->readXSDString(); - $links = $gpx->getLinks(); - if ($links->isEmpty()) { - $state['urlname'] = $text; - } else { - $links->bottom()->setText($text); - } - }; - $struct['elements']['time'] = function ($gpx) { - $gpx->setTime($this->string2DateTime($this->readXSDString())); - }; - $struct['elements']['keywords'] = function ($gpx) { - $keywords = $gpx->getKeywords(); - $words = explode(',', $this->readXSDString()); - foreach ($words as $word) - $keywords[] = trim($word); - }; - $struct['elements']['bounds'] = false; - $struct['extensions'] = function ($gpx) { - $gpx->getExtensions()[] = $this->xml->readOuterXML(); - $this->xml->next(); - }; + protected static function detectRootNamespace(string $data) { + $xml = new XMLReader(); + $xml->XML($data, null, self::XMLREADER_OPTIONS); + $namespace = false; + while ($xml->read()) { + if ($xml->nodeType == XMLReader::ELEMENT) { + $namespace = $xml->namespaceURI; + break; + } } - - $this->readStruct($struct, $result); - return $result; - } - - /** - * Read a metadata type. - * - * Note: This is GPX 1.1 only as GPX 1.0 does not have a metadata type. - * - * @param GPX $gpx The gpx object to populate. - * @return void - * @throws MalformedGPXException If the GPX file was invalid or not supported. - */ - protected function readMetadata(GPX $gpx) - { - $struct = [ - 'elements' => [ - 'name' => function ($gpx) { - $gpx->setName($this->readXSDString()); - }, - 'desc' => function ($gpx) { - $gpx->setDesc($this->readXSDString()); - }, - 'author' => function ($gpx) { - $gpx->setAuthor($this->readPerson()); - }, - 'copyright' => function ($gpx) { - $gpx->setCopyright($this->readCopyright()); - }, - 'link' => function ($gpx) { - $links = $gpx->getLinks(); - $links[] = $this->readLink(); - }, - 'time' => function ($gpx) { - $gpx->setTime($this->string2DateTime($this->readXSDString())); - }, - 'keywords' => function ($gpx) { - $keywords = $gpx->getKeywords(); - $words = explode(',', $this->readXSDString()); - foreach ($words as $word) - $keywords[] = trim($word); - }, - 'bounds' => false, // Not required as it is calculated internally. - 'extensions' => function ($gpx) { - $this->readExtension($gpx->getMetadataExtensions()); - } - ] - ]; - $this->readStruct($struct, $gpx); + if ($namespace === false) + throw new MalformedGPXException(libxml_get_last_error()->message); + $xml->close(); + return $namespace; } /** - * Read a person type. + * Reads a gpx type from an XML document. * - * @return Person The person data. - * @throws MalformedGPXException If the GPX file was invalid or not supported. + * @param XMLReader $xml An XML document with the cursor positioned at the + * start of the gpx type. + * @return GPX A GPX data structure. + * @throws MalformedGPXException If there was a problem parsing the XML. */ - protected function readPerson() + protected static function readGPX(XMLReader $xml) { - $struct = [ - 'elements' => [ - 'name' => function ($person) { - $person->setName($this->readXSDString()); - }, - 'email' => function ($person) { - $id = $this->xml->getAttribute('id'); - if ($id === null) - throw new MalformedGPXException( - 'Missing required attribute "id" in "email"' - ); - $domain = $this->xml->getAttribute('domain'); - if ($domain === null) - throw new MalformedGPXException( - 'Missing required attribute "domain" in "email"' - ); - $person->setEmail($id . '@' . $domain); - $this->xml->read(); - }, - 'link' => function ($person) { - $person->setLink($this->readLink()); + $gpx = new GPX(); + $gpx->setCreator($xml->getAttribute('creator')); + $ns = $xml->namespaceURI; + $inMetadata = false; + while ($valid = ($xml->read() && $xml->isValid())) { + if ($xml->nodeType == XMLReader::ELEMENT) { + if ($xml->namespaceURI == $ns) { + switch ($xml->localName) { + case 'metadata': + $inMetadata = true; + break; + case 'name': + $gpx->setName($xml->readString()); + break; + case 'desc': + $gpx->setDesc($xml->readString()); + break; + case 'author': + if ($inMetadata) { + $gpx->setAuthor(self::readPerson($xml)); + } else { + $person = new Person(); + $person->setName($xml->readString()); + $gpx->setAuthor($person); + } + break; + case 'email': + $person = $gpx->getAuthor(); + if ($person !== null) $gpx->setAuthor($person = new Person()); + $person->setEmail($xml->readString()); + break; + case 'copyright': + $gpx->setCopyright(self::readCopyright($xml)); + break; + case 'link': + $gpx->getLinks()[] = self::readLink($xml); + break; + case 'url': + $gpx->getLinks()[] = new Link($xml->readString()); + break; + case 'urlname': + $links = $gpx->getLinks(); + if (count($links) > 0) + $links[0]->setText($xml->readString()); + break; + case 'time': + $gpx->setTime(new DateTimeImmutable($xml->readString())); + break; + case 'keywords': + $keywords = $gpx->getKeywords(); + foreach (explode(',', $xml->readString()) as $keyword) + $keywords[] = trim($keyword); + break; + case 'wpt': + $gpx->getWaypoints()[] = self::readPoint($xml); + break; + case 'rte': + $gpx->getRoutes()[] = self::readRoute($xml); + break; + case 'trk': + $gpx->getTracks()[] = self::readTrack($xml); + break; + case 'extensions': + $exts = ( + $inMetadata + ? $gpx->getMetadataExtensions() + : $gpx->getExtensions() + ); + foreach (self::readExtensions($xml) as $ext) { + $exts[] = $ext; + } + break; + } + } else { + $gpx->getExtensions()[] = self::readExtension($xml); } - ] - ]; - $person = new Person(); - $this->readStruct($struct, $person); - return $person; - } - - /** - * Read a link type. - * - * @return Link The link data. - * @throws MalformedGPXException If the GPX file was invalid or not supported. - */ - protected function readLink() - { - $href = $this->xml->getAttribute('href'); - if ($href === null) - throw new MalformedGPXException( - 'Missing required attribute "href" in "link"' - ); - $struct = [ - 'elements' => [ - 'text' => function ($link) { - $link->setText($this->readXSDString()); - }, - 'type' => function ($link) { - $link->setType($this->readXSDString()); + } elseif ( + $xml->nodeType == XMLReader::END_ELEMENT + && $xml->namespaceURI == $ns + ) { + switch ($xml->localName) { + case 'metadata': + $inMetadata = false; + break; + case 'gpx': + break 2; } - ] - ]; - $link = new Link($href); - $this->readStruct($struct, $link); - return $link; + } + } + if (!$valid) + throw new MalformedGPXException(libxml_get_last_error()->message); + return $gpx; } /** - * Read a copyright type. + * Reads a rte type from an XML document. * - * @return Copyright The copyright data. - * @throws MalformedGPXException If the GPX file was invalid or not supported. + * @param XMLReader $xml An XML document with the cursor positioned at the + * start of the rte type. + * @return Route A Route data structure. + * @throws MalformedGPXException If there was a problem parsing the XML. */ - protected function readCopyright() + protected static function readRoute(XMLReader $xml) { - $author = $this->xml->getAttribute('author'); - if ($author === null) - throw new MalformedGPXException( - 'Missing required attribute "author" in "copyright"' - ); - $struct = [ - 'elements' => [ - 'year' => function ($copyright) { - try { - $year = $this->readXSDString(); - $copyright->setYear($year); - } catch (InvalidArgumentException $e) { - throw new MalformedGPXException( - sprintf('"%s" is not a valid year', $year) - ); + $route = new Route(); + if ($xml->isEmptyElement) return $route; + $ns = $xml->namespaceURI; + while ($valid = ($xml->read() && $xml->isValid())) { + if ($xml->nodeType == XMLReader::ELEMENT) { + if ($xml->namespaceURI == $ns) { + switch ($xml->localName) { + case 'name': + $route->setName($xml->readString()); + break; + case 'cmt': + $route->setComment($xml->readString()); + break; + case 'desc': + $route->setDescription($xml->readString()); + break; + case 'src': + $route->setSource($xml->readString()); + break; + case 'link': + $route->getLinks()[] = self::readLink($xml); + break; + case 'url': + $route->getLinks()[] = new Link($xml->readString()); + break; + case 'urlname': + $links = $route->getLinks(); + if (count($links) > 0) + $links[0]->setText($xml->readString()); + break; + case 'number': + $route->setNumber($xml->readString()); + break; + case 'type'; + $route->setType($xml->readString()); + break; + case 'extensions': + $exts = $route->getExtensions(); + foreach (self::readExtensions($xml) as $ext) { + $exts[] = $ext; + } + break; + case 'rtept': + $route->getPoints()[] = self::readPoint($xml); + break; } - }, - 'license' => function ($copyright) { - $copyright->setLicense($this->readXSDString()); + } else { + $route->getExtensions()[] = self::readExtension($xml); } - ] - ]; - $copyright = new Copyright($author); - $this->readStruct($struct, $copyright); - return $copyright; - } - - /** - * Read an extensions type. - * - * This is suitable for GPX 1.1 only as private elements are mixed into other - * elements in GPX 1.0. - * - * @param TypedDoublyLinkedList $list The list to fill with extension XML. - * @return void - * @throws MalformedGPXException If the GPX file was invalid. - */ - protected function readExtension(TypedDoublyLinkedList $list) - { - $struct = [ - 'extensions' => function ($list) { - $list[] = $this->xml->readOuterXml(); - $this->xml->next(); + } elseif ($xml->nodeType == XMLReader::END_ELEMENT) { + if ($xml->namespaceURI == $ns && $xml->localName == 'rte') break; } - ]; - $this->readStruct($struct, $list); - } - - /** - * Read a wpt type. - * - * @see https://www.topografix.com/GPX/1/1/#type_wptType - * - * @return Point - * @throws MalformedGPXException If the GPX file was invalid or not supported. - */ - protected function readWpt() - { - try { - $result = new Point( - $this->string2float($this->xml->getAttribute('lat')), - $this->string2float($this->xml->getAttribute('lon')) - ); - $struct = $this->getDataTypeStruct(); - $struct['elements']['ele'] = function ($point) { - $point->setEle($this->string2float($this->readXSDString())); - }; - $struct['elements']['time'] = function ($point) { - $point->setTime($this->string2DateTime($this->readXSDString())); - }; - $struct['elements']['magvar'] = function ($point) { - $point->setMagvar($this->string2float($this->readXSDString())); - }; - $struct['elements']['geoidheight'] = function ($point) { - $point->setGeoidHeight($this->string2float($this->readXSDString())); - }; - $struct['elements']['sym'] = function ($point) { - $point->setSymbol($this->readXSDString()); - }; - // "type" only appears in "wpt" in GPX 1.0 - if ($this->version == '1.0') - $struct['elements']['type'] = function ($point) { - $point->setType($this->readXSDString()); - }; - $struct['elements']['fix'] = function ($point) { - $point->setFix($this->readXSDString()); - }; - $struct['elements']['sat'] = function ($point) { - $point->setSatellites($this->string2int($this->readXSDString())); - }; - $struct['elements']['hdop'] = function ($point) { - $point->setHdop($this->string2float($this->readXSDString())); - }; - $struct['elements']['vdop'] = function ($point) { - $point->setVdop($this->string2float($this->readXSDString())); - }; - $struct['elements']['pdop'] = function ($point) { - $point->setPdop($this->string2float($this->readXSDString())); - }; - $struct['elements']['ageofdgpsdata'] = function ($point) { - $point->setAgeOfDGPSData($this->string2float($this->readXSDString())); - }; - $struct['elements']['dgpsid'] = function ($point) { - $point->setDGPSId($this->string2int($this->readXSDString())); - }; - $this->readStruct($struct, $result); - } catch (DomainException $e) { - throw new MalformedGPXException( - $e->getMessage(), $e->getCode(), $e - ); } - return $result; + if (!$valid) + throw new MalformedGPXException(libxml_get_last_error()->message); + return $route; } /** - * Read a rte type. - * - * @see https://www.topografix.com/GPX/1/1/#type_rteType + * Reads a trk type from an XML document. * - * @return Route - * @throws MalformedGPXException If the GPX file was invalid or not supported. + * @param XMLReader $xml An XML document with the cursor positioned at the + * start of the trk type. + * @return Track A Track data structure. + * @throws MalformedGPXException If there was a problem parsing the XML. */ - protected function readRte() + protected static function readTrack(XMLReader $xml) { - try { - $result = new Route(); - $struct = $this->getDataTypeStruct(); - $struct['elements']['number'] = function ($route) { - $route->setNumber($this->string2int($this->readXSDString())); - }; - $struct['elements']['rtept'] = function ($route) { - $route->getPoints()[] = $this->readWpt(); - }; - $this->readStruct($struct, $result); - } catch (DomainException $e) { - throw new MalformedGPXException( - $e->getMessage(), $e->getCode(), $e - ); - } - return $result; - } - - /** - * Read a trk type. - * - * @see https://www.topografix.com/GPX/1/1/#type_trkType - * - * @return Track - * @throws MalformedGPXException If the GPX file was invalid or not supported. - */ - protected function readTrk() - { - try { - $result = new Track(); - $struct = $this->getDataTypeStruct(); - $struct['elements']['number'] = function ($track) { - $track->setNumber($this->string2int($this->readXSDString())); - }; - $struct['elements']['trkseg'] = function ($track) { - $track->getSegments()[] = $this->readTrkseg(); - }; - $this->readStruct($struct, $result); - } catch (DomainException $e) { - throw new MalformedGPXException( - $e->getMessage(), $e->getCode(), $e - ); + $track = new Track(); + $ns = $xml->namespaceURI; + while ($valid = ($xml->read() && $xml->isValid())) { + if ($xml->nodeType == XMLReader::ELEMENT) { + if ($xml->namespaceURI == $ns) { + switch ($xml->localName) { + case 'name': + $track->setName($xml->readString()); + break; + case 'cmt': + $track->setComment($xml->readString()); + break; + case 'desc': + $track->setDescription($xml->readString()); + break; + case 'src': + $track->setSource($xml->readString()); + break; + case 'link': + $track->getLinks()[] = self::readLink($xml); + break; + case 'url': + $track->getLinks()[] = new Link($xml->readString()); + break; + case 'urlname': + $links = $track->getLinks(); + if (count($links) > 0) + $links[0]->setText($xml->readString()); + break; + case 'number': + $track->setNumber($xml->readString()); + break; + case 'type'; + $track->setType($xml->readString()); + break; + case 'extensions': + $exts = $track->getExtensions(); + foreach (self::readExtensions($xml) as $ext) { + $exts[] = $ext; + } + break; + case 'trkseg': + $track->getSegments()[] = self::readTrackSegment($xml); + break; + } + } else { + $track->getExtensions()[] = self::readExtension($xml); + } + } elseif ($xml->nodeType == XMLReader::END_ELEMENT) { + if ($xml->namespaceURI == $ns && $xml->localName == 'trk') break; + } } - return $result; + if (!$valid) + throw new MalformedGPXException(libxml_get_last_error()->message); + return $track; } /** - * Read a trkseg type. - * - * @see https://www.topografix.com/GPX/1/1/#type_trksegType + * Reads a trkseg type from an XML document. * - * @return TrackSegment - * @throws MalformedGPXException If the GPX file was invalid or not supported. + * @param XMLReader $xml An XML document with the cursor positioned at the + * start of the trkseg type. + * @return TrackSegment A TrackSegment data structure. + * @throws MalformedGPXException If there was a problem parsing the XML. */ - protected function readTrkseg() + protected static function readTrackSegment(XMLReader $xml) { - $result = new TrackSegment(); - $struct = [ - 'elements' => [ - 'trkpt' => function ($segment) { - $segment->getPoints()[] = $this->readWpt(); + $segment = new TrackSegment(); + $ns = $xml->namespaceURI; + while ($valid = ($xml->read() && $xml->isValid())) { + if ($xml->nodeType == XMLReader::ELEMENT) { + if ($xml->namespaceURI == $ns) { + switch ($xml->localName) { + case 'trkpt': + $segment->getPoints()[] = self::readPoint($xml); + break; + case 'extensions': + $exts = $segment->getExtensions(); + foreach (self::readExtensions($xml) as $ext) { + $exts[] = $ext; + } + break; + } } - ] - ]; - if ($this->version == '1.1') - $struct['elements']['extensions'] = function ($segment) { - $this->readExtension($segment->getExtensions()); - }; - $this->readStruct($struct, $result); - return $result; + } elseif ($xml->nodeType == XMLReader::END_ELEMENT) { + if ($xml->namespaceURI == $ns && $xml->localName == 'trkseg') break; + } + } + if (!$valid) + throw new MalformedGPXException(libxml_get_last_error()->message); + return $segment; } /** - * Read a data structure from XML. - * - * This is a generalized function for parsing XML data structures and - * populating values based on data retrieved. The main control element is the - * `$struct` array which holds a list of callable functions which are used to - * modify the supplied object. The structure of the array is: - * - * [ - * 'extensions' => function($object, &$state) {...}, - * 'elements' => [ - * 'nodename' => function($object, &$state) {...} - * ] - * ] - * - * The following keys are defined: - * - * * `extensions` - Call the supplied anonymous function if any XML element - * is encountered in a namespace other than the document - * namespace. - * * `elements` - Call the supplied anonymous function if an XML element - * is encountered in the document namespace with a matching - * node name. + * Reads a wpt type from an XML document. * - * The anonymous function parameters are as follows: - * - * * `$object` - The object to be populated (as passed to this function) - * * `$state` - An array that can be used to store data temporarily while - * the structure is being written. This is useful if a value - * that is being read is split across several XML elements. - * - * @param array $struct a data structure as defined above. - * @param object $object The object to populate with data. - * @return void - * @throws MalformedGPXException If the GPX file was invalid or not supported. + * @param XMLReader $xml An XML document with the cursor positioned at the + * start of the wpt type. + * @return Point A Point data structure. + * @throws MalformedGPXException If there was a problem parsing the XML. */ - protected function readStruct($struct, $object) + protected static function readPoint(XMLReader $xml) { - $xml = $this->xml; - if ($xml->isEmptyElement) { - $xml->read(); - return; - } - $element = $xml->localName; - $xml->read(); - $state = []; - while (true) { - if ( - $xml->nodeType == XMLReader::END_ELEMENT - && $xml->namespaceURI == $this->ns - && $xml->localName == $element - ) return; - if ($xml->nodeType != XMLReader::ELEMENT) { - $xml->read(); - continue; - } - if ( - $xml->namespaceURI == $this->ns - && isset($struct['elements'][$xml->localName]) - ) { - if (is_callable($struct['elements'][$xml->localName])) { - $struct['elements'][$xml->localName]($object, $state); + $point = new Point( + $xml->getAttribute('lat'), + $xml->getAttribute('lon') + ); + if ($xml->isEmptyElement) return $point; + $ns = $xml->namespaceURI; + $name = $xml->localName; + while ($valid = ($xml->read() && $xml->isValid())) { + if ($xml->nodeType == XMLReader::ELEMENT) { + if ($xml->namespaceURI == $ns) { + switch ($xml->localName) { + case 'ele': + $point->setEle($xml->readString()); + break; + case 'time': + $point->setTime(new DateTimeImmutable($xml->readString())); + break; + case 'magvar': + $point->setMagvar($xml->readString()); + break; + case 'geoidheight': + $point->setGeoidHeight($xml->readString()); + break; + case 'name': + $point->setName($xml->readString()); + break; + case 'cmt': + $point->setComment($xml->readString()); + break; + case 'desc': + $point->setDescription($xml->readString()); + break; + case 'src': + $point->setSource($xml->readString()); + break; + case 'link': + $point->getLinks()[] = self::readLink($xml); + break; + case 'url': + $point->getLinks()[] = new Link($xml->readString()); + break; + case 'urlname': + $links = $point->getLinks(); + if (count($links) > 0) + $links[0]->setText($xml->readString()); + break; + case 'sym': + $point->setSymbol($xml->readString()); + break; + case 'type': + $point->setType($xml->readString()); + break; + case 'fix': + $point->setFix($xml->readString()); + break; + case 'sat': + $point->setSatellites($xml->readString()); + break; + case 'hdop': + $point->setHdop($xml->readString()); + break; + case 'vdop': + $point->setVdop($xml->readString()); + break; + case 'pdop': + $point->setPdop($xml->readString()); + break; + case 'ageofdgpsdata': + $point->setAgeOfDGPSData($xml->readString()); + break; + case 'dgpsid': + $point->setDGPSId($xml->readString()); + break; + case 'extensions': + $exts = $point->getExtensions(); + foreach (self::readExtensions($xml) as $ext) { + $exts[] = $ext; + } + break; + } } else { - $xml->read(); + $point->getExtensions()[] = self::readExtension($xml); } - continue; - } - if ( - $xml->namespaceURI != $this->ns - && isset($struct['extensions']) - && is_callable($struct['extensions']) - ) { - $struct['extensions']($object, $state); - continue; + } elseif ($xml->nodeType == XMLReader::END_ELEMENT) { + if ($xml->namespaceURI == $ns && $xml->localName == $name) break; } - throw new MalformedGPXException( - sprintf( - 'Unknown element "%s" in element "%s"', - $xml->localName, - $element - ) - ); } + if (!$valid) + throw new MalformedGPXException(libxml_get_last_error()->message); + return $point; } /** - * Generate a data structure for reading DataType elements. + * Reads a person type from an XML document. * - * @return array + * @param XMLReader $xml An XML document with the cursor positioned at the + * start of the person type. + * @return Person A Person data structure. + * @throws MalformedGPXException If there was a problem parsing the XML. */ - protected function getDataTypeStruct() + protected static function readPerson(XMLReader $xml) { - $result = [ - 'elements' => [ - 'name' => function ($type) { - $type->setName($this->readXSDString()); - }, - 'cmt' => function ($type) { - $type->setComment($this->readXSDString()); - }, - 'desc' => function ($type) { - $type->setDescription($this->readXSDString()); - }, - 'src' => function ($type) { - $type->setSource($this->readXSDString()); - } - ] - ]; - if ($this->version == '1.1') { - $result['elements']['link'] = function ($type) { - $type->getLinks()[] = $this->readLink(); - }; - $result['elements']['type'] = function ($type) { - $type->setType($this->readXSDString()); - }; - $result['elements']['extensions'] = function ($type) { - $this->readExtension($type->getExtensions()); - }; - } else { - $result['elements']['url'] = function ($type, &$state) { - $href = $this->readXSDString(); - $links = $type->getLinks(); - if ($links->isEmpty()) { - $link = new Link($href); - if (isset($state['urlname'])) { - $link->setText($state['urlname']); - unset($state['urlname']); - } - $links[] = $link; - } else { - $links->bottom()->setHref($href); - } - }; - $result['elements']['urlname'] = function ($type, &$state) { - $text = $this->readXSDString(); - $links = $type->getLinks(); - if ($links->isEmpty()) { - $state['urlname'] = $text; - } else { - $links->bottom()->setText($text); + $person = new Person(); + if ($xml->isEmptyElement) return $person; + $name = $xml->localName; + while ($valid = ($xml->read() && $xml->isValid())) { + if ($xml->nodeType == XMLReader::ELEMENT) { + switch ($xml->localName) { + case 'name': + $person->setName($xml->readString()); + break; + case 'email': + $person->setEmail( + $xml->getAttribute('id') . '@' . $xml->getAttribute('domain') + ); + break; + case 'link': + $person->setLink(self::readLink($xml)); + break; } - }; - $result['extensions'] = function ($type) { - $type->getExtensions()[] = $this->xml->readOuterXML(); - $this->xml->next(); - }; + } elseif ($xml->nodeType == XMLReader::END_ELEMENT) { + if ($xml->localName == $name) break; + } } - return $result; + if (!$valid) + throw new MalformedGPXException(libxml_get_last_error()->message); + return $person; } /** - * Read an `xsd:string` type from XML. + * Reads a link type from an XML document. * - * @return string The text string. - * @throws MalformedGPXException If the element has non-text children. + * @param XMLReader $xml An XML document with the cursor positioned at the + * start of the link type. + * @return Link A Link data structure. + * @throws MalformedGPXException If there was a problem parsing the XML. */ - protected function readXSDString() + protected static function readLink(XMLReader $xml) { - $xml = $this->xml; - $result = ''; - if ($xml->nodeType != XMLReader::ELEMENT || $xml->isEmptyElement) - return $result; - $element = $xml->name; - while ($xml->read()) { - switch ($xml->nodeType) { - case XMLReader::TEXT: - case XMLReader::CDATA: - $result .= $xml->value; - break; - case XMLReader::END_ELEMENT: - $xml->read(); - break 2; - default: - throw new MalformedGPXException( - sprintf('Element "%s" contains non-text data.', $element) - ); + $link = new Link($xml->getAttribute('href')); + if ($xml->isEmptyElement) return $link; + while ($valid = ($xml->read() && $xml->isValid())) { + if ($xml->nodeType == XMLReader::ELEMENT) { + switch ($xml->localName) { + case 'text': + $link->setText($xml->readString()); + break; + case 'type': + $link->setType($xml->readString()); + break; + } + } elseif ($xml->nodeType == XMLReader::END_ELEMENT) { + if ($xml->localName == 'link') break; } - } - return $result; + if (!$valid) + throw new MalformedGPXException(libxml_get_last_error()->message); + return $link; } /** - * Convert a string to DateTime. + * Reads a copyright type from an XML document. * - * @param string $timestamp The timestamp to convert. - * @return DateTime The parsed DateTime. - * @throws MalformedGPXException If the timestamp could not be parsed. + * @param XMLReader $xml An XML document with the cursor positioned at the + * start of the copyright type. + * @return Copyright A Copyright data structure. + * @throws MalformedGPXException If there was a problem parsing the XML. */ - protected function string2DateTime(string $timestamp) + protected static function readCopyright(XMLReader $xml) { - try { - $result = new DateTime($timestamp, new DateTimeZone('Z')); - } catch (Exception $e) { - throw new MalformedGPXException( - sprintf('Unknown datetime format "%s"', $timestamp) - ); + $copyright = new Copyright($xml->getAttribute('author')); + if ($xml->isEmptyElement) return $copyright; + $name = $xml->localName; + while ($valid = ($xml->read() && $xml->isValid())) { + if ($xml->nodeType == XMLReader::ELEMENT) { + switch ($xml->localName) { + case 'year': + $copyright->setYear($xml->readString()); + break; + case 'license': + $copyright->setLicense($xml->readString()); + break; + } + } elseif ($xml->nodeType == XMLReader::END_ELEMENT) { + if ($xml->localName == $name) break; + } } - return $result; + if (!$valid) + throw new MalformedGPXException(libxml_get_last_error()->message); + return $copyright; } /** - * Convert a string to a float. + * Reads an extensions type (GPX 1.1 only) from an XML document. * - * @param string $value The string value to convert. - * @return float - * @throws MalformedGPXException If the value is not numeric. + * @param XMLReader $xml An XML document with the cursor positioned at the + * start of the extensions type. + * @return string[] An array of XML snippets describing the extensions. + * @throws MalformedGPXException If there was a problem parsing the XML. */ - protected function string2float(string $value) + protected static function readExtensions(XMLReader $xml) { - if (!is_numeric($value)) - throw new MalformedGPXException( - sprintf('Expected decimal value but got "%s"', $value) - ); - return floatval($value); + $extensions = []; + if (!$xml->isEmptyElement) { + $xml->read(); + do { + switch ($xml->nodeType) { + case XMLReader::ELEMENT: + $extensions[] = $xml->readOuterXML(); + break; + case XMLReader::END_ELEMENT: + break 2; + } + } while ($valid = ($xml->next() && $xml->isValid())); + } + if (!$valid) + throw new MalformedGPXException(libxml_get_last_error()->message); + return $extensions; } /** - * Convert a string to a int. + * Reads a extension (GPX 1.0 only) from an XML document. * - * @param string $value The string value to convert. - * @return int - * @throws MalformedGPXException If the value is not numeric. + * @param XMLReader $xml An XML document with the cursor positioned at the + * start of the extension element. + * @return string An XML snippet describing the extension. + * @throws MalformedGPXException If there was a problem parsing the XML. */ - protected function string2int(string $value) + protected static function readExtension(XMLReader $xml) { - if (!is_numeric($value)) - throw new MalformedGPXException( - sprintf('Expected decimal value but got "%s"', $value) - ); - return intval($value); + $extension = $xml->readOuterXML(); + $depth = 0; + while ($valid = ($xml->read() && $xml->isValid())) { + if ($xml->nodeType == XMLReader::END_ELEMENT && $depth-- == 0) { + break; + } elseif ($xml->nodeType == XMLReader::ELEMENT && !$xml->isEmptyElement) { + $depth++; + } + } + if (!$valid) + throw new MalformedGPXException(libxml_get_last_error()->message); + return $extension; } } diff --git a/src/libgpx/peekstream.php b/src/libgpx/peekstream.php new file mode 100644 index 0000000..5f481fb --- /dev/null +++ b/src/libgpx/peekstream.php @@ -0,0 +1,195 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301, USA. + * + * + */ + +namespace libgpx; + +/** + * A PHP stream wrapper with the ability to peek at the first read. + * + * Note: This class is used to workaround an issue with the `XMLReader` class. + * It should not be called by code outside of libgpx. + * + * @internal + * + * @author Andy Street + */ +class PeekStream +{ + + /** + * All instances of this class with open streams. + * + * @var PeekStream[] + */ + public static $instances = []; + + /** + * The handle to the stream to read from. + * + * @var resource + */ + protected $handle; + + /** + * The stream path where data is loaded from. + * + * @var string + */ + protected $path; + + /** + * Cache of the first successful read from the stream. + * + * @var mixed + */ + protected $first_read; + + /** + * The context for this stream. + * + * Unused but set by PHP so included for completeness. + * + * @var resource + */ + public $context; + + /** + * Remove the protocol from a path. + * + * @param string $path The path including the protocol for this stream wrapper. + * @return string The path without the protocol. + */ + protected function stripProtocol(string $path) + { + $pos = strpos($path, ':'); + return ($pos === false ? null : substr($path, $pos + 3)); + } + + /** + * Open a new stream. + * + * @param string $path The path to open. + * @param string $mode The mode used to open the stream. + * @param int $options Streams API flags. + * @param string $opened_path The path that was actually opened. + * @return boolean If it was possible to open a stream. + */ + public function stream_open( + string $path, + string $mode, + int $options, + &$opened_path + ) { + $this->path = $this->stripProtocol($path); + if ($this->path === null) return false; + + if (!in_array($mode, ['r', 'rb'])) return false; + + $this->handle = fopen($this->path, $mode); + if (!is_resource($this->handle)) return false; + self::$instances[] = $this; + + return true; + } + + /** + * Close a stream. + * + * @return boolean If the stream was closed successfully. + */ + public function stream_close() + { + $c = count(self::$instances); + for ($i = 0; $i < $c; $i++) { + if (self::$instances[$i] === $this) { + unset(self::$instances[$i]); + break; + } + } + return fclose($this->handle); + } + + /** + * Check if the pointer is at the end of the stream. + * + * @return boolean If the pointer is at the end of the stream. + */ + public function stream_eof() + { + return feof($this->handle); + } + + /** + * Read data from the stream. + * + * @param int $count The number of bytes to read. + * @return string The bytes read or false on failure. + */ + public function stream_read(int $count) + { + $data = fread($this->handle, $count); + if ($data !== false && $this->first_read === null) + $this->first_read = $data; + return $data; + } + + /** + * Fetch file information. + * + * @param string $path The path to check. + * @param int $flags PHP streams API flags. + * @return array A `stat` compatible array. + */ + public function url_stat(string $path, int $flags) + { + $path = $this->stripProtocol($path); + if ($path === null) return 0; + return stat($path); + } + + /** + * Fetch the contents of the first read from a stream. + * + * @param string $path The path to peek at. + * @return string|null The first read or null if not available. + */ + public static function peek(string $path) + { + $result = null; + $instance = null; + $c = count(self::$instances); + for ($i = 0; $i < $c; $i++) { + if (self::$instances[$i]->path === $path) { + $instance = self::$instances[$i]; + break; + } + } + if ($instance instanceof PeekStream && is_string($instance->first_read)) { + $result = $instance->first_read; + $instance->first_read = false; + } + return $result; + } + +} -- 2.39.5