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