1<?php
2
3/**
4 * League.Csv (https://csv.thephpleague.com)
5 *
6 * (c) Ignace Nyamagana Butera <nyamsprod@gmail.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12declare(strict_types=1);
13
14namespace League\Csv;
15
16use Generator;
17use SplFileObject;
18use function filter_var;
19use function get_class;
20use function mb_strlen;
21use function rawurlencode;
22use function sprintf;
23use function str_replace;
24use function str_split;
25use function strcspn;
26use function strlen;
27use const FILTER_FLAG_STRIP_HIGH;
28use const FILTER_FLAG_STRIP_LOW;
29use const FILTER_SANITIZE_STRING;
30
31/**
32 * An abstract class to enable CSV document loading.
33 */
34abstract class AbstractCsv implements ByteSequence
35{
36    /**
37     * The stream filter mode (read or write).
38     *
39     * @var int
40     */
41    protected $stream_filter_mode;
42
43    /**
44     * collection of stream filters.
45     *
46     * @var bool[]
47     */
48    protected $stream_filters = [];
49
50    /**
51     * The CSV document BOM sequence.
52     *
53     * @var string|null
54     */
55    protected $input_bom = null;
56
57    /**
58     * The Output file BOM character.
59     *
60     * @var string
61     */
62    protected $output_bom = '';
63
64    /**
65     * the field delimiter (one character only).
66     *
67     * @var string
68     */
69    protected $delimiter = ',';
70
71    /**
72     * the field enclosure character (one character only).
73     *
74     * @var string
75     */
76    protected $enclosure = '"';
77
78    /**
79     * the field escape character (one character only).
80     *
81     * @var string
82     */
83    protected $escape = '\\';
84
85    /**
86     * The CSV document.
87     *
88     * @var SplFileObject|Stream
89     */
90    protected $document;
91
92    /**
93     * Tells whether the Input BOM must be included or skipped.
94     *
95     * @var bool
96     */
97    protected $is_input_bom_included = false;
98
99    /**
100     * New instance.
101     *
102     * @param SplFileObject|Stream $document The CSV Object instance
103     */
104    protected function __construct($document)
105    {
106        $this->document = $document;
107        list($this->delimiter, $this->enclosure, $this->escape) = $this->document->getCsvControl();
108        $this->resetProperties();
109    }
110
111    /**
112     * Reset dynamic object properties to improve performance.
113     */
114    protected function resetProperties()
115    {
116    }
117
118    /**
119     * {@inheritdoc}
120     */
121    public function __destruct()
122    {
123        unset($this->document);
124    }
125
126    /**
127     * {@inheritdoc}
128     */
129    public function __clone()
130    {
131        throw new Exception(sprintf('An object of class %s cannot be cloned', static::class));
132    }
133
134    /**
135     * Return a new instance from a SplFileObject.
136     *
137     * @return static
138     */
139    public static function createFromFileObject(SplFileObject $file)
140    {
141        return new static($file);
142    }
143
144    /**
145     * Return a new instance from a PHP resource stream.
146     *
147     * @param resource $stream
148     *
149     * @return static
150     */
151    public static function createFromStream($stream)
152    {
153        return new static(new Stream($stream));
154    }
155
156    /**
157     * Return a new instance from a string.
158     *
159     * @return static
160     */
161    public static function createFromString(string $content = '')
162    {
163        return new static(Stream::createFromString($content));
164    }
165
166    /**
167     * Return a new instance from a file path.
168     *
169     * @param resource|null $context the resource context
170     *
171     * @return static
172     */
173    public static function createFromPath(string $path, string $open_mode = 'r+', $context = null)
174    {
175        return new static(Stream::createFromPath($path, $open_mode, $context));
176    }
177
178    /**
179     * Returns the current field delimiter.
180     */
181    public function getDelimiter(): string
182    {
183        return $this->delimiter;
184    }
185
186    /**
187     * Returns the current field enclosure.
188     */
189    public function getEnclosure(): string
190    {
191        return $this->enclosure;
192    }
193
194    /**
195     * Returns the pathname of the underlying document.
196     */
197    public function getPathname(): string
198    {
199        return $this->document->getPathname();
200    }
201
202    /**
203     * Returns the current field escape character.
204     */
205    public function getEscape(): string
206    {
207        return $this->escape;
208    }
209
210    /**
211     * Returns the BOM sequence in use on Output methods.
212     */
213    public function getOutputBOM(): string
214    {
215        return $this->output_bom;
216    }
217
218    /**
219     * Returns the BOM sequence of the given CSV.
220     */
221    public function getInputBOM(): string
222    {
223        if (null !== $this->input_bom) {
224            return $this->input_bom;
225        }
226
227        $this->document->setFlags(SplFileObject::READ_CSV);
228        $this->document->rewind();
229        $this->input_bom = bom_match((string) $this->document->fread(4));
230
231        return $this->input_bom;
232    }
233
234    /**
235     * Returns the stream filter mode.
236     */
237    public function getStreamFilterMode(): int
238    {
239        return $this->stream_filter_mode;
240    }
241
242    /**
243     * Tells whether the stream filter capabilities can be used.
244     */
245    public function supportsStreamFilter(): bool
246    {
247        return $this->document instanceof Stream;
248    }
249
250    /**
251     * Tell whether the specify stream filter is attach to the current stream.
252     */
253    public function hasStreamFilter(string $filtername): bool
254    {
255        return $this->stream_filters[$filtername] ?? false;
256    }
257
258    /**
259     * Tells whether the BOM can be stripped if presents.
260     */
261    public function isInputBOMIncluded(): bool
262    {
263        return $this->is_input_bom_included;
264    }
265
266    /**
267     * Retuns the CSV document as a Generator of string chunk.
268     *
269     * @param int $length number of bytes read
270     *
271     * @throws Exception if the number of bytes is lesser than 1
272     */
273    public function chunk(int $length): Generator
274    {
275        if ($length < 1) {
276            throw new InvalidArgument(sprintf('%s() expects the length to be a positive integer %d given', __METHOD__, $length));
277        }
278
279        $input_bom = $this->getInputBOM();
280        $this->document->rewind();
281        $this->document->setFlags(0);
282        $this->document->fseek(strlen($input_bom));
283        foreach (str_split($this->output_bom.$this->document->fread($length), $length) as $chunk) {
284            yield $chunk;
285        }
286
287        while ($this->document->valid()) {
288            yield $this->document->fread($length);
289        }
290    }
291
292    /**
293     * DEPRECATION WARNING! This method will be removed in the next major point release.
294     *
295     * @deprecated deprecated since version 9.1.0
296     * @see AbstractCsv::getContent
297     *
298     * Retrieves the CSV content
299     */
300    public function __toString(): string
301    {
302        return $this->getContent();
303    }
304
305    /**
306     * Retrieves the CSV content.
307     */
308    public function getContent(): string
309    {
310        $raw = '';
311        foreach ($this->chunk(8192) as $chunk) {
312            $raw .= $chunk;
313        }
314
315        return $raw;
316    }
317
318    /**
319     * Outputs all data on the CSV file.
320     *
321     * @return int Returns the number of characters read from the handle
322     *             and passed through to the output.
323     */
324    public function output(string $filename = null): int
325    {
326        if (null !== $filename) {
327            $this->sendHeaders($filename);
328        }
329
330        $this->document->rewind();
331        if (!$this->is_input_bom_included) {
332            $this->document->fseek(strlen($this->getInputBOM()));
333        }
334
335        echo $this->output_bom;
336
337        return strlen($this->output_bom) + $this->document->fpassthru();
338    }
339
340    /**
341     * Send the CSV headers.
342     *
343     * Adapted from Symfony\Component\HttpFoundation\ResponseHeaderBag::makeDisposition
344     *
345     * @throws Exception if the submitted header is invalid according to RFC 6266
346     *
347     * @see https://tools.ietf.org/html/rfc6266#section-4.3
348     */
349    protected function sendHeaders(string $filename)
350    {
351        if (strlen($filename) != strcspn($filename, '\\/')) {
352            throw new InvalidArgument('The filename cannot contain the "/" and "\\" characters.');
353        }
354
355        $flag = FILTER_FLAG_STRIP_LOW;
356        if (strlen($filename) !== mb_strlen($filename)) {
357            $flag |= FILTER_FLAG_STRIP_HIGH;
358        }
359
360        $filenameFallback = str_replace('%', '', filter_var($filename, FILTER_SANITIZE_STRING, $flag));
361
362        $disposition = sprintf('attachment; filename="%s"', str_replace('"', '\\"', $filenameFallback));
363        if ($filename !== $filenameFallback) {
364            $disposition .= sprintf("; filename*=utf-8''%s", rawurlencode($filename));
365        }
366
367        header('Content-Type: text/csv');
368        header('Content-Transfer-Encoding: binary');
369        header('Content-Description: File Transfer');
370        header('Content-Disposition: '.$disposition);
371    }
372
373    /**
374     * Sets the field delimiter.
375     *
376     * @throws Exception If the Csv control character is not one character only.
377     *
378     * @return static
379     */
380    public function setDelimiter(string $delimiter): self
381    {
382        if ($delimiter === $this->delimiter) {
383            return $this;
384        }
385
386        if (1 === strlen($delimiter)) {
387            $this->delimiter = $delimiter;
388            $this->resetProperties();
389
390            return $this;
391        }
392
393        throw new InvalidArgument(sprintf('%s() expects delimiter to be a single character %s given', __METHOD__, $delimiter));
394    }
395
396    /**
397     * Sets the field enclosure.
398     *
399     * @throws Exception If the Csv control character is not one character only.
400     *
401     * @return static
402     */
403    public function setEnclosure(string $enclosure): self
404    {
405        if ($enclosure === $this->enclosure) {
406            return $this;
407        }
408
409        if (1 === strlen($enclosure)) {
410            $this->enclosure = $enclosure;
411            $this->resetProperties();
412
413            return $this;
414        }
415
416        throw new InvalidArgument(sprintf('%s() expects enclosure to be a single character %s given', __METHOD__, $enclosure));
417    }
418
419    /**
420     * Sets the field escape character.
421     *
422     * @throws Exception If the Csv control character is not one character only.
423     *
424     * @return static
425     */
426    public function setEscape(string $escape): self
427    {
428        if ($escape === $this->escape) {
429            return $this;
430        }
431
432        if ('' === $escape || 1 === strlen($escape)) {
433            $this->escape = $escape;
434            $this->resetProperties();
435
436            return $this;
437        }
438
439        throw new InvalidArgument(sprintf('%s() expects escape to be a single character or the empty string %s given', __METHOD__, $escape));
440    }
441
442    /**
443     * Enables BOM Stripping.
444     *
445     * @return static
446     */
447    public function skipInputBOM(): self
448    {
449        $this->is_input_bom_included = false;
450
451        return $this;
452    }
453
454    /**
455     * Disables skipping Input BOM.
456     *
457     * @return static
458     */
459    public function includeInputBOM(): self
460    {
461        $this->is_input_bom_included = true;
462
463        return $this;
464    }
465
466    /**
467     * Sets the BOM sequence to prepend the CSV on output.
468     *
469     * @return static
470     */
471    public function setOutputBOM(string $str): self
472    {
473        $this->output_bom = $str;
474
475        return $this;
476    }
477
478    /**
479     * append a stream filter.
480     *
481     * @param null|mixed $params
482     *
483     * @throws Exception If the stream filter API can not be used
484     *
485     * @return static
486     */
487    public function addStreamFilter(string $filtername, $params = null): self
488    {
489        if (!$this->document instanceof Stream) {
490            throw new UnavailableFeature('The stream filter API can not be used with a '.get_class($this->document).' instance.');
491        }
492
493        $this->document->appendFilter($filtername, $this->stream_filter_mode, $params);
494        $this->stream_filters[$filtername] = true;
495        $this->resetProperties();
496        $this->input_bom = null;
497
498        return $this;
499    }
500}
501