1<?php
2/**
3 * Copyright 2012-2017 Horde LLC (http://www.horde.org/)
4 *
5 * See the enclosed file LICENSE for license information (LGPL). If you
6 * did not receive this file, see http://www.horde.org/licenses/lgpl21.
7 *
8 * @category  Horde
9 * @copyright 2012-2017 Horde LLC
10 * @license   http://www.horde.org/licenses/lgpl21 LGPL 2.1
11 * @package   Stream
12 */
13
14/**
15 * Object that adds convenience/utility methods to interacting with PHP
16 * streams.
17 *
18 * @author    Michael Slusarz <slusarz@horde.org>
19 * @category  Horde
20 * @copyright 2012-2017 Horde LLC
21 * @license   http://www.horde.org/licenses/lgpl21 LGPL 2.1
22 * @package   Stream
23 *
24 * @property boolean $utf8_char  Parse character as UTF-8 data instead of
25 *                               single byte (@since 1.4.0).
26 */
27class Horde_Stream implements Serializable
28{
29    /**
30     * Stream resource.
31     *
32     * @var resource
33     */
34    public $stream;
35
36    /**
37     * Configuration parameters.
38     *
39     * @var array
40     */
41    protected $_params;
42
43    /**
44     * Parse character as UTF-8 data instead of single byte.
45     *
46     * @var boolean
47     */
48    protected $_utf8_char = false;
49
50    /**
51     * Constructor.
52     *
53     * @param array $opts  Configuration options.
54     */
55    public function __construct(array $opts = array())
56    {
57        $this->_params = $opts;
58        $this->_init();
59    }
60
61    /**
62     * Initialization method.
63     */
64    protected function _init()
65    {
66        // Sane default: read-write, 0-length stream.
67        if (!$this->stream) {
68            $this->stream = @fopen('php://temp', 'r+');
69        }
70    }
71
72    /**
73     */
74    public function __get($name)
75    {
76        switch ($name) {
77        case 'utf8_char':
78            return $this->_utf8_char;
79        }
80    }
81
82    /**
83     */
84    public function __set($name, $value)
85    {
86        switch ($name) {
87        case 'utf8_char':
88            $this->_utf8_char = (bool)$value;
89            break;
90        }
91    }
92
93    /**
94     */
95    public function __clone()
96    {
97        $data = strval($this);
98        $this->stream = null;
99        $this->_init();
100        $this->add($data);
101    }
102
103    /**
104     * String representation of object.
105     *
106     * @since 1.1.0
107     *
108     * @return string  The full stream converted to a string.
109     */
110    public function __toString()
111    {
112        $this->rewind();
113        return $this->substring();
114    }
115
116    /**
117     * Adds data to the stream.
118     *
119     * @param mixed $data     Data to add to the stream. Can be a resource,
120     *                        Horde_Stream object, or a string(-ish) value.
121     * @param boolean $reset  Reset stream pointer to initial position after
122     *                        adding?
123     */
124    public function add($data, $reset = false)
125    {
126        if ($reset) {
127            $pos = $this->pos();
128        }
129
130        if (is_resource($data)) {
131            $dpos = ftell($data);
132            while (!feof($data)) {
133                $this->add(fread($data, 8192));
134            }
135            fseek($data, $dpos);
136        } elseif ($data instanceof Horde_Stream) {
137            $dpos = $data->pos();
138            while (!$data->eof()) {
139                $this->add($data->substring(0, 65536));
140            }
141            $data->seek($dpos, false);
142        } else {
143            fwrite($this->stream, $data);
144        }
145
146        if ($reset) {
147            $this->seek($pos, false);
148        }
149    }
150
151    /**
152     * Returns the length of the data. Does not change the stream position.
153     *
154     * @param boolean $utf8  If true, determines the UTF-8 length of the
155     *                       stream (as of 1.4.0). If false, determines the
156     *                       byte length of the stream.
157     *
158     * @return integer  Stream size.
159     *
160     * @throws Horde_Stream_Exception
161     */
162    public function length($utf8 = false)
163    {
164        $pos = $this->pos();
165
166        if ($utf8 && $this->_utf8_char) {
167            $this->rewind();
168            $len = 0;
169            while ($this->getChar() !== false) {
170                ++$len;
171            }
172        } elseif (!$this->end()) {
173            throw new Horde_Stream_Exception('ERROR');
174        } else {
175            $len = $this->pos();
176        }
177
178        if (!$this->seek($pos, false)) {
179            throw new Horde_Stream_Exception('ERROR');
180        }
181
182        return $len;
183    }
184
185    /**
186     * Get a string up to a certain character (or EOF).
187     *
188     * @param string $end   The character to stop reading at. As of 1.4.0,
189     *                      $char can be a multi-character UTF-8 string.
190     * @param boolean $all  If true, strips all repetitions of $end from
191     *                      the end. If false, stops at the first instance
192     *                      of $end. (@since 1.5.0)
193     *
194     * @return string  The string up to $end (stream is positioned after the
195     *                 end character(s), all of which are stripped from the
196     *                 return data).
197     */
198    public function getToChar($end, $all = true)
199    {
200        if (($len = strlen($end)) === 1) {
201            $out = '';
202            do {
203                if (($tmp = stream_get_line($this->stream, 8192, $end)) === false) {
204                    return $out;
205                }
206
207                $out .= $tmp;
208                if ((strlen($tmp) < 8192) || ($this->peek(-1) == $end)) {
209                    break;
210                }
211            } while (true);
212        } else {
213            $res = $this->search($end);
214
215            if (is_null($res)) {
216                return $this->substring();
217            }
218
219            $out = substr($this->getString(null, $res + $len - 1), 0, $len * -1);
220        }
221
222        /* Remove all further characters also. */
223        if ($all) {
224            while ($this->peek($len) == $end) {
225                $this->seek($len);
226            }
227        }
228
229        return $out;
230    }
231
232    /**
233     * Return the current character(s) without moving the pointer.
234     *
235     * @param integer $length  The peek length (since 1.4.0).
236     *
237     * @return string  The current character.
238     */
239    public function peek($length = 1)
240    {
241        $out = '';
242
243        for ($i = 0; $i < $length; ++$i) {
244            if (($c = $this->getChar()) === false) {
245                break;
246            }
247            $out .= $c;
248        }
249
250        $this->seek(strlen($out) * -1);
251
252        return $out;
253    }
254
255    /**
256     * Search for character(s) and return its position.
257     *
258     * @param string $char      The character to search for. As of 1.4.0,
259     *                          $char can be a multi-character UTF-8 string.
260     * @param boolean $reverse  Do a reverse search?
261     * @param boolean $reset    Reset the pointer to the original position?
262     *
263     * @return mixed  The start position of the search string (integer), or
264     *                null if character not found.
265     */
266    public function search($char, $reverse = false, $reset = true)
267    {
268        $found_pos = null;
269
270        if ($len = strlen($char)) {
271            $pos = $this->pos();
272            $single_char = ($len === 1);
273
274            do {
275                if ($reverse) {
276                    for ($i = $pos - 1; $i >= 0; --$i) {
277                        $this->seek($i, false);
278                        $c = $this->peek();
279                        if ($c == ($single_char ? $char : substr($char, 0, strlen($c)))) {
280                            $found_pos = $i;
281                            break;
282                        }
283                    }
284                } else {
285                    /* Optimization for the common use case of searching for
286                     * a single character in byte data. Reduces calling
287                     * getChar() a bunch of times. */
288                    $fgetc = ($single_char && !$this->_utf8_char);
289
290                    while (($c = ($fgetc ? fgetc($this->stream) : $this->getChar())) !== false) {
291                        if ($c == ($single_char ? $char : substr($char, 0, strlen($c)))) {
292                            $found_pos = $this->pos() - ($single_char ? 1 : strlen($c));
293                            break;
294                        }
295                    }
296                }
297
298                if ($single_char ||
299                    is_null($found_pos) ||
300                    ($this->getString($found_pos, $found_pos + $len - 1) == $char)) {
301                    break;
302                }
303
304                $this->seek($found_pos + ($reverse ? 0 : 1), false);
305                $found_pos = null;
306            } while (true);
307
308            $this->seek(
309                ($reset || is_null($found_pos)) ? $pos : $found_pos,
310                false
311            );
312        }
313
314        return $found_pos;
315    }
316
317    /**
318     * Returns the stream (or a portion of it) as a string. Position values
319     * are the byte position in the stream.
320     *
321     * @param integer $start  The starting position. If positive, start from
322     *                        this position. If negative, starts this length
323     *                        back from the current position. If null, starts
324     *                        from the current position.
325     * @param integer $end    The ending position relative to the beginning of
326     *                        the stream (if positive). If negative, end this
327     *                        length back from the end of the stream. If null,
328     *                        reads to the end of the stream.
329     *
330     * @return string  A string.
331     */
332    public function getString($start = null, $end = null)
333    {
334        if (!is_null($start) && ($start >= 0)) {
335            $this->seek($start, false);
336            $start = 0;
337        }
338
339        if (is_null($end)) {
340            $len = null;
341        } else {
342            $end = ($end >= 0)
343                ? $end - $this->pos() + 1
344                : $this->length() - $this->pos() + $end;
345            $len = max($end, 0);
346        }
347
348        return $this->substring($start, $len);
349    }
350
351    /**
352     * Return part of the stream as a string.
353     *
354     * @since 1.4.0
355     *
356     * @param integer $start   Start, as an offset from the current postion.
357     * @param integer $length  Length of string to return. If null, returns
358     *                         rest of the stream. If negative, this many
359     *                         characters will be omitted from the end of the
360     *                         stream.
361     * @param boolean $char    If true, $start/$length is the length in
362     *                         characters. If false, $start/$length is the
363     *                         length in bytes.
364     *
365     * @return string  The substring.
366     */
367    public function substring($start = 0, $length = null, $char = false)
368    {
369        if ($start !== 0) {
370            $this->seek($start, true, $char);
371        }
372
373        $out = '';
374        $to_end = is_null($length);
375
376        /* If length is greater than remaining stream, use more efficient
377         * algorithm below. Also, if doing a negative length, deal with that
378         * below also. */
379        if ($char &&
380            $this->_utf8_char &&
381            !$to_end &&
382            ($length >= 0) &&
383            ($length < ($this->length() - $this->pos()))) {
384            while ($length-- && (($char = $this->getChar()) !== false)) {
385                $out .= $char;
386            }
387            return $out;
388        }
389
390        if (!$to_end && ($length < 0)) {
391            $pos = $this->pos();
392            $this->end();
393            $this->seek($length, true, $char);
394            $length = $this->pos() - $pos;
395            $this->seek($pos, false);
396            if ($length < 0) {
397                return '';
398            }
399        }
400
401        while (!feof($this->stream) && ($to_end || $length)) {
402            $read = fread($this->stream, $to_end ? 16384 : $length);
403            $out .= $read;
404            if (!$to_end) {
405                $length -= strlen($read);
406            }
407        }
408
409        return $out;
410    }
411
412    /**
413     * Auto-determine the EOL string.
414     *
415     * @since 1.3.0
416     *
417     * @return string  The EOL string, or null if no EOL found.
418     */
419    public function getEOL()
420    {
421        $pos = $this->pos();
422
423        $this->rewind();
424        $pos2 = $this->search("\n", false, false);
425        if ($pos2) {
426            $this->seek(-1);
427            $eol = ($this->getChar() == "\r")
428                ? "\r\n"
429                : "\n";
430        } else {
431            $eol = is_null($pos2)
432                ? null
433                : "\n";
434        }
435
436        $this->seek($pos, false);
437
438        return $eol;
439    }
440
441    /**
442     * Return a character from the string.
443     *
444     * @since 1.4.0
445     *
446     * @return string  Character (single byte, or UTF-8 character if
447     *                 $utf8_char is true).
448     */
449    public function getChar()
450    {
451        $char = fgetc($this->stream);
452        if (!$this->_utf8_char) {
453            return $char;
454        }
455
456        $c = ord($char);
457        if ($c < 0x80) {
458            return $char;
459        }
460
461        if ($c < 0xe0) {
462            $n = 1;
463        } elseif ($c < 0xf0) {
464            $n = 2;
465        } elseif ($c < 0xf8) {
466            $n = 3;
467        } else {
468            throw new Horde_Stream_Exception('ERROR');
469        }
470
471        for ($i = 0; $i < $n; ++$i) {
472            if (($c = fgetc($this->stream)) === false) {
473                throw new Horde_Stream_Exception('ERROR');
474            }
475            $char .= $c;
476        }
477
478        return $char;
479    }
480
481    /**
482     * Return the current stream pointer position.
483     *
484     * @since 1.4.0
485     *
486     * @return mixed  The current position (integer), or false.
487     */
488    public function pos()
489    {
490        return ftell($this->stream);
491    }
492
493    /**
494     * Rewind the internal stream to the beginning.
495     *
496     * @since 1.4.0
497     *
498     * @return boolean  True if successful.
499     */
500    public function rewind()
501    {
502        return rewind($this->stream);
503    }
504
505    /**
506     * Move internal pointer.
507     *
508     * @since 1.4.0
509     *
510     * @param integer $offset  The offset.
511     * @param boolean $curr    If true, offset is from current position. If
512     *                         false, offset is from beginning of stream.
513     * @param boolean $char    If true, $offset is the length in characters.
514     *                         If false, $offset is the length in bytes.
515     *
516     * @return boolean  True if successful.
517     */
518    public function seek($offset = 0, $curr = true, $char = false)
519    {
520        if (!$offset) {
521            return (bool)$curr ?: $this->rewind();
522        }
523
524        if ($offset < 0) {
525            if (!$curr) {
526                return true;
527            } elseif (abs($offset) > $this->pos()) {
528                return $this->rewind();
529            }
530        }
531
532        if ($char && $this->_utf8_char) {
533            if ($offset > 0) {
534                if (!$curr) {
535                    $this->rewind();
536                }
537
538                do {
539                    $this->getChar();
540                } while (--$offset);
541            } else {
542                $pos = $this->pos();
543                $offset = abs($offset);
544
545                while ($pos-- && $offset) {
546                    fseek($this->stream, -1, SEEK_CUR);
547                    if ((ord($this->peek()) & 0xC0) != 0x80) {
548                        --$offset;
549                    }
550                }
551            }
552
553            return true;
554        }
555
556        return (fseek($this->stream, $offset, $curr ? SEEK_CUR : SEEK_SET) === 0);
557    }
558
559    /**
560     * Move internal pointer to the end of the stream.
561     *
562     * @since 1.4.0
563     *
564     * @param integer $offset  Move this offset from the end.
565     *
566     * @return boolean  True if successful.
567     */
568    public function end($offset = 0)
569    {
570        return (fseek($this->stream, $offset, SEEK_END) === 0);
571    }
572
573    /**
574     * Has the end of the stream been reached?
575     *
576     * @since 1.4.0
577     *
578     * @return boolean  True if the end of the stream has been reached.
579     */
580    public function eof()
581    {
582        return feof($this->stream);
583    }
584
585    /**
586     * Close the stream.
587     *
588     * @since 1.4.0
589     */
590    public function close()
591    {
592        if ($this->stream) {
593            fclose($this->stream);
594        }
595    }
596
597    /* Serializable methods. */
598
599    /**
600     */
601    public function serialize()
602    {
603        $this->_params['_pos'] = $this->pos();
604
605        return json_encode(array(
606            strval($this),
607            $this->_params
608        ));
609    }
610
611    /**
612     */
613    public function unserialize($data)
614    {
615        $this->_init();
616
617        $data = json_decode($data, true);
618        $this->add($data[0]);
619        $this->seek($data[1]['_pos'], false);
620        unset($data[1]['_pos']);
621        $this->_params = $data[1];
622    }
623
624}
625