LICENSE 0000644 00000002107 13764477326 0005574 0 ustar 00 The MIT License (MIT)
Copyright (c) 2013-2017 ignace nyamagana butera
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
autoload.php 0000644 00000000550 13764477326 0007110 0 ustar 00
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace League\Csv;
use DOMDocument;
use DOMElement;
use DOMException;
use function preg_match;
/**
* Converts tabular data into an HTML Table string.
*/
class HTMLConverter
{
/**
* table class attribute value.
*
* @var string
*/
protected $class_name = 'table-csv-data';
/**
* table id attribute value.
*
* @var string
*/
protected $id_value = '';
/**
* @var XMLConverter
*/
protected $xml_converter;
/**
* New Instance.
*/
public function __construct()
{
$this->xml_converter = (new XMLConverter())
->rootElement('table')
->recordElement('tr')
->fieldElement('td')
;
}
/**
* Converts a tabular data collection into a HTML table string.
*
* @param string[] $header_record An optional array of headers outputted using the`` section
* @param string[] $footer_record An optional array of footers to output to the table using `
` and `
` elements
*/
public function convert(iterable $records, array $header_record = [], array $footer_record = []): string
{
$doc = new DOMDocument('1.0');
if ([] === $header_record && [] === $footer_record) {
$table = $this->xml_converter->import($records, $doc);
$this->addHTMLAttributes($table);
$doc->appendChild($table);
/** @var string $content */
$content = $doc->saveHTML();
return $content;
}
$table = $doc->createElement('table');
$this->addHTMLAttributes($table);
$this->appendHeaderSection('thead', $header_record, $table);
$this->appendHeaderSection('tfoot', $footer_record, $table);
$table->appendChild($this->xml_converter->rootElement('tbody')->import($records, $doc));
$doc->appendChild($table);
/** @var string $content */
$content = $doc->saveHTML();
return $content;
}
/**
* Creates a DOMElement representing a HTML table heading section.
*/
protected function appendHeaderSection(string $node_name, array $record, DOMElement $table): void
{
if ([] === $record) {
return;
}
/** @var DOMDocument $ownerDocument */
$ownerDocument = $table->ownerDocument;
$node = $this->xml_converter
->rootElement($node_name)
->recordElement('tr')
->fieldElement('th')
->import([$record], $ownerDocument)
;
/** @var DOMElement $element */
foreach ($node->getElementsByTagName('th') as $element) {
$element->setAttribute('scope', 'col');
}
$table->appendChild($node);
}
/**
* Adds class and id attributes to an HTML tag.
*/
protected function addHTMLAttributes(DOMElement $node): void
{
$node->setAttribute('class', $this->class_name);
$node->setAttribute('id', $this->id_value);
}
/**
* HTML table class name setter.
*
* @throws DOMException if the id_value contains any type of whitespace
*/
public function table(string $class_name, string $id_value = ''): self
{
if (1 === preg_match(",\s,", $id_value)) {
throw new DOMException("the id attribute's value must not contain whitespace (spaces, tabs etc.)");
}
$clone = clone $this;
$clone->class_name = $class_name;
$clone->id_value = $id_value;
return $clone;
}
/**
* HTML tr record offset attribute setter.
*/
public function tr(string $record_offset_attribute_name): self
{
$clone = clone $this;
$clone->xml_converter = $this->xml_converter->recordElement('tr', $record_offset_attribute_name);
return $clone;
}
/**
* HTML td field name attribute setter.
*/
public function td(string $fieldname_attribute_name): self
{
$clone = clone $this;
$clone->xml_converter = $this->xml_converter->fieldElement('td', $fieldname_attribute_name);
return $clone;
}
}
src/XMLConverter.php 0000644 00000012641 13764477326 0010423 0 ustar 00
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace League\Csv;
use DOMAttr;
use DOMDocument;
use DOMElement;
use DOMException;
/**
* Converts tabular data into a DOMDocument object.
*/
class XMLConverter
{
/**
* XML Root name.
*
* @var string
*/
protected $root_name = 'csv';
/**
* XML Node name.
*
* @var string
*/
protected $record_name = 'row';
/**
* XML Item name.
*
* @var string
*/
protected $field_name = 'cell';
/**
* XML column attribute name.
*
* @var string
*/
protected $column_attr = '';
/**
* XML offset attribute name.
*
* @var string
*/
protected $offset_attr = '';
/**
* Conversion method list.
*
* @var array
*/
protected $encoder = [
'field' => [
true => 'fieldToElementWithAttribute',
false => 'fieldToElement',
],
'record' => [
true => 'recordToElementWithAttribute',
false => 'recordToElement',
],
];
/**
* Convert a Record collection into a DOMDocument.
*/
public function convert(iterable $records): DOMDocument
{
$doc = new DOMDocument('1.0');
$node = $this->import($records, $doc);
$doc->appendChild($node);
return $doc;
}
/**
* Create a new DOMElement related to the given DOMDocument.
*
* **DOES NOT** attach to the DOMDocument
*/
public function import(iterable $records, DOMDocument $doc): DOMElement
{
$field_encoder = $this->encoder['field']['' !== $this->column_attr];
$record_encoder = $this->encoder['record']['' !== $this->offset_attr];
$root = $doc->createElement($this->root_name);
foreach ($records as $offset => $record) {
$node = $this->$record_encoder($doc, $record, $field_encoder, $offset);
$root->appendChild($node);
}
return $root;
}
/**
* Convert a CSV record into a DOMElement and
* adds its offset as DOMElement attribute.
*/
protected function recordToElementWithAttribute(
DOMDocument $doc,
array $record,
string $field_encoder,
int $offset
): DOMElement {
$node = $this->recordToElement($doc, $record, $field_encoder);
$node->setAttribute($this->offset_attr, (string) $offset);
return $node;
}
/**
* Convert a CSV record into a DOMElement.
*/
protected function recordToElement(DOMDocument $doc, array $record, string $field_encoder): DOMElement
{
$node = $doc->createElement($this->record_name);
foreach ($record as $node_name => $value) {
$item = $this->$field_encoder($doc, (string) $value, $node_name);
$node->appendChild($item);
}
return $node;
}
/**
* Convert Cell to Item.
*
* Convert the CSV item into a DOMElement and adds the item offset
* as attribute to the returned DOMElement
*
* @param int|string $node_name
*/
protected function fieldToElementWithAttribute(DOMDocument $doc, string $value, $node_name): DOMElement
{
$item = $this->fieldToElement($doc, $value);
$item->setAttribute($this->column_attr, (string) $node_name);
return $item;
}
/**
* Convert Cell to Item.
*
* @param string $value Record item value
*/
protected function fieldToElement(DOMDocument $doc, string $value): DOMElement
{
$item = $doc->createElement($this->field_name);
$item->appendChild($doc->createTextNode($value));
return $item;
}
/**
* XML root element setter.
*/
public function rootElement(string $node_name): self
{
$clone = clone $this;
$clone->root_name = $this->filterElementName($node_name);
return $clone;
}
/**
* Filter XML element name.
*
* @throws DOMException If the Element name is invalid
*/
protected function filterElementName(string $value): string
{
return (new DOMElement($value))->tagName;
}
/**
* XML Record element setter.
*/
public function recordElement(string $node_name, string $record_offset_attribute_name = ''): self
{
$clone = clone $this;
$clone->record_name = $this->filterElementName($node_name);
$clone->offset_attr = $this->filterAttributeName($record_offset_attribute_name);
return $clone;
}
/**
* Filter XML attribute name.
*
* @param string $value Element name
*
* @throws DOMException If the Element attribute name is invalid
*/
protected function filterAttributeName(string $value): string
{
if ('' === $value) {
return $value;
}
return (new DOMAttr($value))->name;
}
/**
* XML Field element setter.
*/
public function fieldElement(string $node_name, string $fieldname_attribute_name = ''): self
{
$clone = clone $this;
$clone->field_name = $this->filterElementName($node_name);
$clone->column_attr = $this->filterAttributeName($fieldname_attribute_name);
return $clone;
}
}
src/MapIterator.php 0000644 00000001761 13764477326 0010323 0 ustar 00
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace League\Csv;
use IteratorIterator;
use Traversable;
/**
* Map value from an iterator before yielding.
*
* @internal used internally to modify CSV content
*/
class MapIterator extends IteratorIterator
{
/**
* The callback to apply on all InnerIterator current value.
*
* @var callable
*/
protected $callable;
/**
* New instance.
*/
public function __construct(Traversable $iterator, callable $callable)
{
parent::__construct($iterator);
$this->callable = $callable;
}
/**
* @return mixed The value of the current element.
*/
public function current()
{
return ($this->callable)(parent::current(), $this->key());
}
}
src/Exception.php 0000644 00000000565 13764477326 0010033 0 ustar 00
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace League\Csv;
/**
* League Csv Base Exception.
*/
class Exception extends \Exception
{
}
src/InvalidArgument.php 0000644 00000000572 13764477326 0011164 0 ustar 00
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace League\Csv;
/**
* InvalidArgument Exception.
*/
class InvalidArgument extends Exception
{
}
src/EscapeFormula.php 0000644 00000007450 13764477326 0010623 0 ustar 00
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace League\Csv;
use InvalidArgumentException;
use function array_fill_keys;
use function array_keys;
use function array_map;
use function array_merge;
use function array_unique;
use function is_object;
use function is_string;
use function method_exists;
use function sprintf;
/**
* A Formatter to tackle CSV Formula Injection.
*
* @see http://georgemauer.net/2017/10/07/csv-injection.html
*/
class EscapeFormula
{
/**
* Spreadsheet formula starting character.
*/
const FORMULA_STARTING_CHARS = ['=', '-', '+', '@'];
/**
* Effective Spreadsheet formula starting characters.
*
* @var array
*/
protected $special_chars = [];
/**
* Escape character to escape each CSV formula field.
*
* @var string
*/
protected $escape;
/**
* New instance.
*
* @param string $escape escape character to escape each CSV formula field
* @param string[] $special_chars additional spreadsheet formula starting characters
*
*/
public function __construct(string $escape = "\t", array $special_chars = [])
{
$this->escape = $escape;
if ([] !== $special_chars) {
$special_chars = $this->filterSpecialCharacters(...$special_chars);
}
$chars = array_merge(self::FORMULA_STARTING_CHARS, $special_chars);
$chars = array_unique($chars);
$this->special_chars = array_fill_keys($chars, 1);
}
/**
* Filter submitted special characters.
*
* @param string ...$characters
*
* @throws InvalidArgumentException if the string is not a single character
*
* @return string[]
*/
protected function filterSpecialCharacters(string ...$characters): array
{
foreach ($characters as $str) {
if (1 != strlen($str)) {
throw new InvalidArgumentException(sprintf('The submitted string %s must be a single character', $str));
}
}
return $characters;
}
/**
* Returns the list of character the instance will escape.
*
* @return string[]
*/
public function getSpecialCharacters(): array
{
return array_keys($this->special_chars);
}
/**
* Returns the escape character.
*/
public function getEscape(): string
{
return $this->escape;
}
/**
* League CSV formatter hook.
*
* @see escapeRecord
*/
public function __invoke(array $record): array
{
return $this->escapeRecord($record);
}
/**
* Escape a CSV record.
*/
public function escapeRecord(array $record): array
{
return array_map([$this, 'escapeField'], $record);
}
/**
* Escape a CSV cell if its content is stringable.
*
* @param mixed $cell the content of the cell
*
* @return mixed|string the escaped content
*/
protected function escapeField($cell)
{
if (!$this->isStringable($cell)) {
return $cell;
}
$str_cell = (string) $cell;
if (isset($str_cell[0], $this->special_chars[$str_cell[0]])) {
return $this->escape.$str_cell;
}
return $cell;
}
/**
* Tells whether the submitted value is stringable.
*
* @param mixed $value value to check if it is stringable
*/
protected function isStringable($value): bool
{
if (is_string($value)) {
return true;
}
return is_object($value)
&& method_exists($value, '__toString');
}
}
src/ResultSet.php 0000644 00000015660 13764477326 0010031 0 ustar 00
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace League\Csv;
use CallbackFilterIterator;
use Iterator;
use JsonSerializable;
use LimitIterator;
use function array_flip;
use function array_search;
use function is_string;
use function iterator_count;
use function iterator_to_array;
use function sprintf;
/**
* Represents the result set of a {@link Reader} processed by a {@link Statement}.
*/
class ResultSet implements TabularDataReader, JsonSerializable
{
/**
* The CSV records collection.
*
* @var Iterator
*/
protected $records;
/**
* The CSV records collection header.
*
* @var array
*/
protected $header = [];
/**
* New instance.
*/
public function __construct(Iterator $records, array $header)
{
$this->validateHeader($header);
$this->records = $records;
$this->header = $header;
}
/**
* @throws SyntaxError if the header syntax is invalid
*/
protected function validateHeader(array $header): void
{
if ($header !== array_unique(array_filter($header, 'is_string'))) {
throw new SyntaxError('The header record must be an empty or a flat array with unique string values.');
}
}
/**
* {@inheritdoc}
*/
public function __destruct()
{
unset($this->records);
}
/**
* Returns a new instance from a League\Csv\Reader object.
*/
public static function createFromTabularDataReader(TabularDataReader $reader): self
{
return new self($reader->getRecords(), $reader->getHeader());
}
/**
* Returns the header associated with the result set.
*
* @return string[]
*/
public function getHeader(): array
{
return $this->header;
}
/**
* {@inheritdoc}
*/
public function getIterator(): Iterator
{
return $this->getRecords();
}
/**
* {@inheritdoc}
*/
public function getRecords(array $header = []): Iterator
{
$this->validateHeader($header);
$records = $this->combineHeader($header);
foreach ($records as $offset => $value) {
yield $offset => $value;
}
}
/**
* Combine the header to each record if present.
*/
protected function combineHeader(array $header): Iterator
{
if ($header === $this->header || [] === $header) {
return $this->records;
}
$field_count = count($header);
$mapper = static function (array $record) use ($header, $field_count): array {
if (count($record) != $field_count) {
$record = array_slice(array_pad($record, $field_count, null), 0, $field_count);
}
/** @var array $assocRecord */
$assocRecord = array_combine($header, $record);
return $assocRecord;
};
return new MapIterator($this->records, $mapper);
}
/**
* {@inheritdoc}
*/
public function count(): int
{
return iterator_count($this->records);
}
/**
* {@inheritdoc}
*/
public function jsonSerialize(): array
{
return iterator_to_array($this->records, false);
}
/**
* {@inheritdoc}
*/
public function fetchOne(int $nth_record = 0): array
{
if ($nth_record < 0) {
throw new InvalidArgument(sprintf('%s() expects the submitted offset to be a positive integer or 0, %s given', __METHOD__, $nth_record));
}
$iterator = new LimitIterator($this->records, $nth_record, 1);
$iterator->rewind();
return (array) $iterator->current();
}
/**
* {@inheritdoc}
*/
public function fetchColumn($index = 0): Iterator
{
$offset = $this->getColumnIndex($index, __METHOD__.'() expects the column index to be a valid string or integer, `%s` given');
$filter = static function (array $record) use ($offset): bool {
return isset($record[$offset]);
};
$select = static function (array $record) use ($offset): string {
return $record[$offset];
};
$iterator = new MapIterator(new CallbackFilterIterator($this->records, $filter), $select);
foreach ($iterator as $tKey => $tValue) {
yield $tKey => $tValue;
}
}
/**
* Filter a column name against the header if any.
*
* @param string|int $field the field name or the field index
* @param string $error_message the associated error message
*
* @return string|int
*/
protected function getColumnIndex($field, string $error_message)
{
if (is_string($field)) {
return $this->getColumnIndexByValue($field, $error_message);
}
return $this->getColumnIndexByKey($field, $error_message);
}
/**
* Returns the selected column name.
*
* @throws Exception if the column is not found
*/
protected function getColumnIndexByValue(string $value, string $error_message): string
{
if (false !== array_search($value, $this->header, true)) {
return $value;
}
throw new InvalidArgument(sprintf($error_message, $value));
}
/**
* Returns the selected column name according to its offset.
*
* @throws Exception if the field is invalid or not found
*
* @return int|string
*/
protected function getColumnIndexByKey(int $index, string $error_message)
{
if ($index < 0) {
throw new InvalidArgument($error_message);
}
if ([] === $this->header) {
return $index;
}
$value = array_search($index, array_flip($this->header), true);
if (false !== $value) {
return $value;
}
throw new InvalidArgument(sprintf($error_message, $index));
}
/**
* {@inheritdoc}
*/
public function fetchPairs($offset_index = 0, $value_index = 1): Iterator
{
$offset = $this->getColumnIndex($offset_index, __METHOD__.'() expects the offset index value to be a valid string or integer, `%s` given');
$value = $this->getColumnIndex($value_index, __METHOD__.'() expects the value index value to be a valid string or integer, `%s` given');
$filter = static function (array $record) use ($offset): bool {
return isset($record[$offset]);
};
$select = static function (array $record) use ($offset, $value): array {
return [$record[$offset], $record[$value] ?? null];
};
$iterator = new MapIterator(new CallbackFilterIterator($this->records, $filter), $select);
foreach ($iterator as $pair) {
yield $pair[0] => $pair[1];
}
}
}
src/Stream.php 0000644 00000030250 13764477326 0007322 0 ustar 00
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace League\Csv;
use SeekableIterator;
use SplFileObject;
use TypeError;
use function array_keys;
use function array_walk_recursive;
use function fclose;
use function feof;
use function fflush;
use function fgetcsv;
use function fgets;
use function fopen;
use function fpassthru;
use function fputcsv;
use function fread;
use function fseek;
use function fwrite;
use function get_resource_type;
use function gettype;
use function is_resource;
use function rewind;
use function sprintf;
use function stream_filter_append;
use function stream_filter_remove;
use function stream_get_meta_data;
use function strlen;
use const PHP_VERSION_ID;
use const SEEK_SET;
/**
* An object oriented API to handle a PHP stream resource.
*
* @internal used internally to iterate over a stream resource
*/
class Stream implements SeekableIterator
{
/**
* Attached filters.
*
* @var array>
*/
protected $filters = [];
/**
* stream resource.
*
* @var resource
*/
protected $stream;
/**
* Tell whether the stream should be closed on object destruction.
*
* @var bool
*/
protected $should_close_stream = false;
/**
* Current iterator value.
*
* @var mixed can be a null false or a scalar type value
*/
protected $value;
/**
* Current iterator key.
*
* @var int
*/
protected $offset;
/**
* Flags for the Document.
*
* @var int
*/
protected $flags = 0;
/**
* the field delimiter (one character only).
*
* @var string
*/
protected $delimiter = ',';
/**
* the field enclosure character (one character only).
*
* @var string
*/
protected $enclosure = '"';
/**
* the field escape character (one character only).
*
* @var string
*/
protected $escape = '\\';
/**
* Tell whether the current stream is seekable;.
*
* @var bool
*/
protected $is_seekable = false;
/**
* New instance.
*
* @param mixed $stream stream type resource
*/
public function __construct($stream)
{
if (!is_resource($stream)) {
throw new TypeError(sprintf('Argument passed must be a stream resource, %s given', gettype($stream)));
}
if ('stream' !== ($type = get_resource_type($stream))) {
throw new TypeError(sprintf('Argument passed must be a stream resource, %s resource given', $type));
}
$this->is_seekable = stream_get_meta_data($stream)['seekable'];
$this->stream = $stream;
}
/**
* {@inheritdoc}
*/
public function __destruct()
{
$walker = static function ($filter): bool {
return @stream_filter_remove($filter);
};
array_walk_recursive($this->filters, $walker);
if ($this->should_close_stream && is_resource($this->stream)) {
fclose($this->stream);
}
unset($this->stream);
}
/**
* {@inheritdoc}
*/
public function __clone()
{
throw new Exception(sprintf('An object of class %s cannot be cloned', static::class));
}
/**
* {@inheritdoc}
*/
public function __debugInfo(): array
{
return stream_get_meta_data($this->stream) + [
'delimiter' => $this->delimiter,
'enclosure' => $this->enclosure,
'escape' => $this->escape,
'stream_filters' => array_keys($this->filters),
];
}
/**
* Return a new instance from a file path.
*
* @param resource|null $context
*
* @throws Exception if the stream resource can not be created
*/
public static function createFromPath(string $path, string $open_mode = 'r', $context = null): self
{
$args = [$path, $open_mode];
if (null !== $context) {
$args[] = false;
$args[] = $context;
}
$resource = @fopen(...$args);
if (!is_resource($resource)) {
throw new Exception(sprintf('`%s`: failed to open stream: No such file or directory', $path));
}
$instance = new self($resource);
$instance->should_close_stream = true;
return $instance;
}
/**
* Return a new instance from a string.
*/
public static function createFromString(string $content = ''): self
{
/** @var resource $resource */
$resource = fopen('php://temp', 'r+');
fwrite($resource, $content);
$instance = new self($resource);
$instance->should_close_stream = true;
return $instance;
}
/**
* Return the URI of the underlying stream.
*/
public function getPathname(): string
{
return stream_get_meta_data($this->stream)['uri'];
}
/**
* append a filter.
*
* @see http://php.net/manual/en/function.stream-filter-append.php
*
* @param null|mixed $params
* @throws Exception if the filter can not be appended
*/
public function appendFilter(string $filtername, int $read_write, $params = null): void
{
$res = @stream_filter_append($this->stream, $filtername, $read_write, $params);
if (!is_resource($res)) {
throw new InvalidArgument(sprintf('unable to locate filter `%s`', $filtername));
}
$this->filters[$filtername][] = $res;
}
/**
* Set CSV control.
*
* @see http://php.net/manual/en/SplFileObject.setcsvcontrol.php
*/
public function setCsvControl(string $delimiter = ',', string $enclosure = '"', string $escape = '\\'): void
{
list($this->delimiter, $this->enclosure, $this->escape) = $this->filterControl($delimiter, $enclosure, $escape, __METHOD__);
}
/**
* Filter Csv control characters.
*
* @throws Exception If the Csv control character is not one character only.
*/
protected function filterControl(string $delimiter, string $enclosure, string $escape, string $caller): array
{
if (1 !== strlen($delimiter)) {
throw new InvalidArgument(sprintf('%s() expects delimiter to be a single character', $caller));
}
if (1 !== strlen($enclosure)) {
throw new InvalidArgument(sprintf('%s() expects enclosure to be a single character', $caller));
}
if (1 === strlen($escape) || ('' === $escape && 70400 <= PHP_VERSION_ID)) {
return [$delimiter, $enclosure, $escape];
}
throw new InvalidArgument(sprintf('%s() expects escape to be a single character', $caller));
}
/**
* Set CSV control.
*
* @see http://php.net/manual/en/SplFileObject.getcsvcontrol.php
*
* @return string[]
*/
public function getCsvControl(): array
{
return [$this->delimiter, $this->enclosure, $this->escape];
}
/**
* Set CSV stream flags.
*
* @see http://php.net/manual/en/SplFileObject.setflags.php
*/
public function setFlags(int $flags): void
{
$this->flags = $flags;
}
/**
* Write a field array as a CSV line.
*
* @see http://php.net/manual/en/SplFileObject.fputcsv.php
*
* @return int|false
*/
public function fputcsv(array $fields, string $delimiter = ',', string $enclosure = '"', string $escape = '\\')
{
$controls = $this->filterControl($delimiter, $enclosure, $escape, __METHOD__);
return fputcsv($this->stream, $fields, ...$controls);
}
/**
* Get line number.
*
* @see http://php.net/manual/en/SplFileObject.key.php
*
* @return int
*/
public function key()
{
return $this->offset;
}
/**
* Read next line.
*
* @see http://php.net/manual/en/SplFileObject.next.php
*/
public function next(): void
{
$this->value = false;
$this->offset++;
}
/**
* Rewind the file to the first line.
*
* @see http://php.net/manual/en/SplFileObject.rewind.php
*
* @throws Exception if the stream resource is not seekable
*/
public function rewind(): void
{
if (!$this->is_seekable) {
throw new Exception('stream does not support seeking');
}
rewind($this->stream);
$this->offset = 0;
$this->value = false;
if (0 !== ($this->flags & SplFileObject::READ_AHEAD)) {
$this->current();
}
}
/**
* Not at EOF.
*
* @see http://php.net/manual/en/SplFileObject.valid.php
*
* @return bool
*/
public function valid()
{
if (0 !== ($this->flags & SplFileObject::READ_AHEAD)) {
return $this->current() !== false;
}
return !feof($this->stream);
}
/**
* Retrieves the current line of the file.
*
* @see http://php.net/manual/en/SplFileObject.current.php
*
* @return mixed The value of the current element.
*/
public function current()
{
if (false !== $this->value) {
return $this->value;
}
$this->value = $this->getCurrentRecord();
return $this->value;
}
/**
* Retrieves the current line as a CSV Record.
*
* @return array|false|null
*/
protected function getCurrentRecord()
{
do {
$ret = fgetcsv($this->stream, 0, $this->delimiter, $this->enclosure, $this->escape);
} while ((0 !== ($this->flags & SplFileObject::SKIP_EMPTY)) && $ret !== null && $ret !== false && $ret[0] === null);
return $ret;
}
/**
* Seek to specified line.
*
* @see http://php.net/manual/en/SplFileObject.seek.php
*
* @param int $position
* @throws Exception if the position is negative
*/
public function seek($position): void
{
if ($position < 0) {
throw new Exception(sprintf('%s() can\'t seek stream to negative line %d', __METHOD__, $position));
}
$this->rewind();
while ($this->key() !== $position && $this->valid()) {
$this->current();
$this->next();
}
if (0 !== $position) {
$this->offset--;
}
$this->current();
}
/**
* Output all remaining data on a file pointer.
*
* @see http://php.net/manual/en/SplFileObject.fpatssthru.php
*
* @return int|false
*/
public function fpassthru()
{
return fpassthru($this->stream);
}
/**
* Read from file.
*
* @see http://php.net/manual/en/SplFileObject.fread.php
*
* @param int $length The number of bytes to read
*
* @return string|false
*/
public function fread(int $length)
{
return fread($this->stream, $length);
}
/**
* Gets a line from file.
*
* @see http://php.net/manual/en/SplFileObject.fgets.php
*
* @return string|false
*/
public function fgets()
{
return fgets($this->stream);
}
/**
* Seek to a position.
*
* @see http://php.net/manual/en/SplFileObject.fseek.php
*
* @throws Exception if the stream resource is not seekable
*/
public function fseek(int $offset, int $whence = SEEK_SET): int
{
if (!$this->is_seekable) {
throw new Exception('stream does not support seeking');
}
return fseek($this->stream, $offset, $whence);
}
/**
* Write to stream.
*
* @see http://php.net/manual/en/SplFileObject.fwrite.php
*
* @return int|false
*/
public function fwrite(string $str, int $length = null)
{
$args = [$this->stream, $str];
if (null !== $length) {
$args[] = $length;
}
return fwrite(...$args);
}
/**
* Flushes the output to a file.
*
* @see http://php.net/manual/en/SplFileObject.fwrite.php
*/
public function fflush(): bool
{
return fflush($this->stream);
}
}
src/SyntaxError.php 0000644 00000000562 13764477326 0010372 0 ustar 00
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace League\Csv;
/**
* SyntaxError Exception.
*/
class SyntaxError extends Exception
{
}
src/Reader.php 0000644 00000025155 13764477326 0007301 0 ustar 00
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace League\Csv;
use CallbackFilterIterator;
use Iterator;
use JsonSerializable;
use League\Csv\Polyfill\EmptyEscapeParser;
use SplFileObject;
use function array_combine;
use function array_filter;
use function array_pad;
use function array_slice;
use function array_unique;
use function count;
use function is_array;
use function iterator_count;
use function iterator_to_array;
use function mb_strlen;
use function mb_substr;
use function sprintf;
use function strlen;
use function substr;
use const PHP_VERSION_ID;
use const STREAM_FILTER_READ;
/**
* A class to parse and read records from a CSV document.
*/
class Reader extends AbstractCsv implements TabularDataReader, JsonSerializable
{
/**
* header offset.
*
* @var int|null
*/
protected $header_offset;
/**
* header record.
*
* @var string[]
*/
protected $header = [];
/**
* records count.
*
* @var int
*/
protected $nb_records = -1;
/**
* {@inheritdoc}
*/
protected $stream_filter_mode = STREAM_FILTER_READ;
/**
* @var bool
*/
protected $is_empty_records_included = false;
/**
* {@inheritdoc}
*/
public static function createFromPath(string $path, string $open_mode = 'r', $context = null)
{
return parent::createFromPath($path, $open_mode, $context);
}
/**
* {@inheritdoc}
*/
protected function resetProperties(): void
{
parent::resetProperties();
$this->nb_records = -1;
$this->header = [];
}
/**
* Returns the header offset.
*
* If no CSV header offset is set this method MUST return null
*
*/
public function getHeaderOffset(): ?int
{
return $this->header_offset;
}
/**
* {@inheritDoc}
*/
public function getHeader(): array
{
if (null === $this->header_offset) {
return $this->header;
}
if ([] !== $this->header) {
return $this->header;
}
$this->header = $this->setHeader($this->header_offset);
return $this->header;
}
/**
* Determine the CSV record header.
*
* @throws Exception If the header offset is set and no record is found or is the empty array
*
* @return string[]
*/
protected function setHeader(int $offset): array
{
$header = $this->seekRow($offset);
if (in_array($header, [[], [null]], true)) {
throw new SyntaxError(sprintf('The header record does not exist or is empty at offset: `%s`', $offset));
}
if (0 !== $offset) {
return $header;
}
$header = $this->removeBOM($header, mb_strlen($this->getInputBOM()), $this->enclosure);
if ([''] === $header) {
throw new SyntaxError(sprintf('The header record does not exist or is empty at offset: `%s`', $offset));
}
return $header;
}
/**
* Returns the row at a given offset.
*/
protected function seekRow(int $offset): array
{
foreach ($this->getDocument() as $index => $record) {
if ($offset === $index) {
return $record;
}
}
return [];
}
/**
* Returns the document as an Iterator.
*/
protected function getDocument(): Iterator
{
if (70400 > PHP_VERSION_ID && '' === $this->escape) {
$this->document->setCsvControl($this->delimiter, $this->enclosure);
return EmptyEscapeParser::parse($this->document);
}
$this->document->setFlags(SplFileObject::READ_CSV | SplFileObject::READ_AHEAD);
$this->document->setCsvControl($this->delimiter, $this->enclosure, $this->escape);
$this->document->rewind();
return $this->document;
}
/**
* Strip the BOM sequence from a record.
*
* @param string[] $record
*
* @return string[]
*/
protected function removeBOM(array $record, int $bom_length, string $enclosure): array
{
if (0 === $bom_length) {
return $record;
}
$record[0] = mb_substr($record[0], $bom_length);
if ($enclosure.$enclosure != substr($record[0].$record[0], strlen($record[0]) - 1, 2)) {
return $record;
}
$record[0] = substr($record[0], 1, -1);
return $record;
}
/**
* {@inheritdoc}
*/
public function fetchColumn($index = 0): Iterator
{
return ResultSet::createFromTabularDataReader($this)->fetchColumn($index);
}
/**
* {@inheritdoc}
*/
public function fetchOne(int $nth_record = 0): array
{
return ResultSet::createFromTabularDataReader($this)->fetchOne($nth_record);
}
/**
* {@inheritdoc}
*/
public function fetchPairs($offset_index = 0, $value_index = 1): Iterator
{
return ResultSet::createFromTabularDataReader($this)->fetchPairs($offset_index, $value_index);
}
/**
* {@inheritdoc}
*/
public function count(): int
{
if (-1 === $this->nb_records) {
$this->nb_records = iterator_count($this->getRecords());
}
return $this->nb_records;
}
/**
* {@inheritdoc}
*/
public function getIterator(): Iterator
{
return $this->getRecords();
}
/**
* {@inheritdoc}
*/
public function jsonSerialize(): array
{
return iterator_to_array($this->getRecords(), false);
}
/**
* {@inheritDoc}
*/
public function getRecords(array $header = []): Iterator
{
$header = $this->computeHeader($header);
$normalized = function ($record): bool {
return is_array($record) && ($this->is_empty_records_included || $record != [null]);
};
$bom = '';
if (!$this->is_input_bom_included) {
$bom = $this->getInputBOM();
}
$document = $this->getDocument();
$records = $this->stripBOM(new CallbackFilterIterator($document, $normalized), $bom);
if (null !== $this->header_offset) {
$records = new CallbackFilterIterator($records, function (array $record, int $offset): bool {
return $offset !== $this->header_offset;
});
}
if ($this->is_empty_records_included) {
$normalized_empty_records = static function (array $record): array {
if ([null] === $record) {
return [];
}
return $record;
};
return $this->combineHeader(new MapIterator($records, $normalized_empty_records), $header);
}
return $this->combineHeader($records, $header);
}
/**
* Returns the header to be used for iteration.
*
* @param string[] $header
*
* @throws Exception If the header contains non unique column name
*
* @return string[]
*/
protected function computeHeader(array $header)
{
if ([] === $header) {
$header = $this->getHeader();
}
if ($header === array_unique(array_filter($header, 'is_string'))) {
return $header;
}
throw new SyntaxError('The header record must be an empty or a flat array with unique string values.');
}
/**
* Combine the CSV header to each record if present.
*
* @param string[] $header
*/
protected function combineHeader(Iterator $iterator, array $header): Iterator
{
if ([] === $header) {
return $iterator;
}
$field_count = count($header);
$mapper = static function (array $record) use ($header, $field_count): array {
if (count($record) != $field_count) {
$record = array_slice(array_pad($record, $field_count, null), 0, $field_count);
}
/** @var array $assocRecord */
$assocRecord = array_combine($header, $record);
return $assocRecord;
};
return new MapIterator($iterator, $mapper);
}
/**
* Strip the BOM sequence from the returned records if necessary.
*/
protected function stripBOM(Iterator $iterator, string $bom): Iterator
{
if ('' === $bom) {
return $iterator;
}
$bom_length = mb_strlen($bom);
$mapper = function (array $record, int $index) use ($bom_length): array {
if (0 !== $index) {
return $record;
}
$record = $this->removeBOM($record, $bom_length, $this->enclosure);
if ([''] === $record) {
return [null];
}
return $record;
};
$filter = function (array $record): bool {
return $this->is_empty_records_included || $record != [null];
};
return new CallbackFilterIterator(new MapIterator($iterator, $mapper), $filter);
}
/**
* Selects the record to be used as the CSV header.
*
* Because the header is represented as an array, to be valid
* a header MUST contain only unique string value.
*
* @param int|null $offset the header record offset
*
* @throws Exception if the offset is a negative integer
*
* @return static
*/
public function setHeaderOffset(?int $offset): self
{
if ($offset === $this->header_offset) {
return $this;
}
if (null !== $offset && 0 > $offset) {
throw new InvalidArgument(__METHOD__.'() expects 1 Argument to be greater or equal to 0');
}
$this->header_offset = $offset;
$this->resetProperties();
return $this;
}
/**
* Enable skipping empty records.
*/
public function skipEmptyRecords(): self
{
if ($this->is_empty_records_included) {
$this->is_empty_records_included = false;
$this->nb_records = -1;
}
return $this;
}
/**
* Disable skipping empty records.
*/
public function includeEmptyRecords(): self
{
if (!$this->is_empty_records_included) {
$this->is_empty_records_included = true;
$this->nb_records = -1;
}
return $this;
}
/**
* Tells whether empty records are skipped by the instance.
*/
public function isEmptyRecordsIncluded(): bool
{
return $this->is_empty_records_included;
}
}
src/UnavailableFeature.php 0000644 00000000610 13764477326 0011623 0 ustar 00
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace League\Csv;
/**
* StreamFilterSupportMissing Exception.
*/
class UnavailableFeature extends Exception
{
}
src/functions_include.php 0000644 00000000517 13764477326 0011605 0 ustar 00
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
if (!function_exists('League\Csv\bom_match')) {
require __DIR__.'/functions.php';
}
src/AbstractCsv.php 0000644 00000027740 13764477326 0010320 0 ustar 00
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace League\Csv;
use Generator;
use SplFileObject;
use function filter_var;
use function get_class;
use function mb_strlen;
use function rawurlencode;
use function sprintf;
use function str_replace;
use function str_split;
use function strcspn;
use function strlen;
use const FILTER_FLAG_STRIP_HIGH;
use const FILTER_FLAG_STRIP_LOW;
use const FILTER_SANITIZE_STRING;
/**
* An abstract class to enable CSV document loading.
*/
abstract class AbstractCsv implements ByteSequence
{
/**
* The stream filter mode (read or write).
*
* @var int
*/
protected $stream_filter_mode;
/**
* collection of stream filters.
*
* @var bool[]
*/
protected $stream_filters = [];
/**
* The CSV document BOM sequence.
*
* @var string|null
*/
protected $input_bom = null;
/**
* The Output file BOM character.
*
* @var string
*/
protected $output_bom = '';
/**
* the field delimiter (one character only).
*
* @var string
*/
protected $delimiter = ',';
/**
* the field enclosure character (one character only).
*
* @var string
*/
protected $enclosure = '"';
/**
* the field escape character (one character only).
*
* @var string
*/
protected $escape = '\\';
/**
* The CSV document.
*
* @var SplFileObject|Stream
*/
protected $document;
/**
* Tells whether the Input BOM must be included or skipped.
*
* @var bool
*/
protected $is_input_bom_included = false;
/**
* New instance.
*
* @param SplFileObject|Stream $document The CSV Object instance
*/
protected function __construct($document)
{
$this->document = $document;
[$this->delimiter, $this->enclosure, $this->escape] = $this->document->getCsvControl();
$this->resetProperties();
}
/**
* Reset dynamic object properties to improve performance.
*/
protected function resetProperties(): void
{
}
/**
* {@inheritdoc}
*/
public function __destruct()
{
unset($this->document);
}
/**
* {@inheritdoc}
*/
public function __clone()
{
throw new Exception(sprintf('An object of class %s cannot be cloned', static::class));
}
/**
* Return a new instance from a SplFileObject.
*
* @return static
*/
public static function createFromFileObject(SplFileObject $file)
{
return new static($file);
}
/**
* Return a new instance from a PHP resource stream.
*
* @param resource $stream
*
* @return static
*/
public static function createFromStream($stream)
{
return new static(new Stream($stream));
}
/**
* Return a new instance from a string.
*
* @return static
*/
public static function createFromString(string $content = '')
{
return new static(Stream::createFromString($content));
}
/**
* Return a new instance from a file path.
*
* @param resource|null $context the resource context
*
* @return static
*/
public static function createFromPath(string $path, string $open_mode = 'r+', $context = null)
{
return new static(Stream::createFromPath($path, $open_mode, $context));
}
/**
* Returns the current field delimiter.
*/
public function getDelimiter(): string
{
return $this->delimiter;
}
/**
* Returns the current field enclosure.
*/
public function getEnclosure(): string
{
return $this->enclosure;
}
/**
* Returns the pathname of the underlying document.
*/
public function getPathname(): string
{
return $this->document->getPathname();
}
/**
* Returns the current field escape character.
*/
public function getEscape(): string
{
return $this->escape;
}
/**
* Returns the BOM sequence in use on Output methods.
*/
public function getOutputBOM(): string
{
return $this->output_bom;
}
/**
* Returns the BOM sequence of the given CSV.
*/
public function getInputBOM(): string
{
if (null !== $this->input_bom) {
return $this->input_bom;
}
$this->document->setFlags(SplFileObject::READ_CSV);
$this->document->rewind();
$this->input_bom = bom_match((string) $this->document->fread(4));
return $this->input_bom;
}
/**
* Returns the stream filter mode.
*/
public function getStreamFilterMode(): int
{
return $this->stream_filter_mode;
}
/**
* Tells whether the stream filter capabilities can be used.
*/
public function supportsStreamFilter(): bool
{
return $this->document instanceof Stream;
}
/**
* Tell whether the specify stream filter is attach to the current stream.
*/
public function hasStreamFilter(string $filtername): bool
{
return $this->stream_filters[$filtername] ?? false;
}
/**
* Tells whether the BOM can be stripped if presents.
*/
public function isInputBOMIncluded(): bool
{
return $this->is_input_bom_included;
}
/**
* Retuns the CSV document as a Generator of string chunk.
*
* @param int $length number of bytes read
*
* @throws Exception if the number of bytes is lesser than 1
*/
public function chunk(int $length): Generator
{
if ($length < 1) {
throw new InvalidArgument(sprintf('%s() expects the length to be a positive integer %d given', __METHOD__, $length));
}
$input_bom = $this->getInputBOM();
$this->document->rewind();
$this->document->setFlags(0);
$this->document->fseek(strlen($input_bom));
/** @var array $chunks */
$chunks = str_split($this->output_bom.$this->document->fread($length), $length);
foreach ($chunks as $chunk) {
yield $chunk;
}
while ($this->document->valid()) {
yield $this->document->fread($length);
}
}
/**
* DEPRECATION WARNING! This method will be removed in the next major point release.
*
* @deprecated deprecated since version 9.1.0
* @see AbstractCsv::getContent
*
* Retrieves the CSV content
*/
public function __toString(): string
{
return $this->getContent();
}
/**
* Retrieves the CSV content.
*/
public function getContent(): string
{
$raw = '';
foreach ($this->chunk(8192) as $chunk) {
$raw .= $chunk;
}
return $raw;
}
/**
* Outputs all data on the CSV file.
*
* @return int Returns the number of characters read from the handle
* and passed through to the output.
*/
public function output(string $filename = null): int
{
if (null !== $filename) {
$this->sendHeaders($filename);
}
$this->document->rewind();
if (!$this->is_input_bom_included) {
$this->document->fseek(strlen($this->getInputBOM()));
}
echo $this->output_bom;
return strlen($this->output_bom) + (int) $this->document->fpassthru();
}
/**
* Send the CSV headers.
*
* Adapted from Symfony\Component\HttpFoundation\ResponseHeaderBag::makeDisposition
*
* @throws Exception if the submitted header is invalid according to RFC 6266
*
* @see https://tools.ietf.org/html/rfc6266#section-4.3
*/
protected function sendHeaders(string $filename): void
{
if (strlen($filename) != strcspn($filename, '\\/')) {
throw new InvalidArgument('The filename cannot contain the "/" and "\\" characters.');
}
$flag = FILTER_FLAG_STRIP_LOW;
if (strlen($filename) !== mb_strlen($filename)) {
$flag |= FILTER_FLAG_STRIP_HIGH;
}
/** @var string $filtered_name */
$filtered_name = filter_var($filename, FILTER_SANITIZE_STRING, $flag);
$filename_fallback = str_replace('%', '', $filtered_name);
$disposition = sprintf('attachment; filename="%s"', str_replace('"', '\\"', $filename_fallback));
if ($filename !== $filename_fallback) {
$disposition .= sprintf("; filename*=utf-8''%s", rawurlencode($filename));
}
header('Content-Type: text/csv');
header('Content-Transfer-Encoding: binary');
header('Content-Description: File Transfer');
header('Content-Disposition: '.$disposition);
}
/**
* Sets the field delimiter.
*
* @throws Exception If the Csv control character is not one character only.
*
* @return static
*/
public function setDelimiter(string $delimiter): self
{
if ($delimiter === $this->delimiter) {
return $this;
}
if (1 === strlen($delimiter)) {
$this->delimiter = $delimiter;
$this->resetProperties();
return $this;
}
throw new InvalidArgument(sprintf('%s() expects delimiter to be a single character %s given', __METHOD__, $delimiter));
}
/**
* Sets the field enclosure.
*
* @throws Exception If the Csv control character is not one character only.
*
* @return static
*/
public function setEnclosure(string $enclosure): self
{
if ($enclosure === $this->enclosure) {
return $this;
}
if (1 === strlen($enclosure)) {
$this->enclosure = $enclosure;
$this->resetProperties();
return $this;
}
throw new InvalidArgument(sprintf('%s() expects enclosure to be a single character %s given', __METHOD__, $enclosure));
}
/**
* Sets the field escape character.
*
* @throws Exception If the Csv control character is not one character only.
*
* @return static
*/
public function setEscape(string $escape): self
{
if ($escape === $this->escape) {
return $this;
}
if ('' === $escape || 1 === strlen($escape)) {
$this->escape = $escape;
$this->resetProperties();
return $this;
}
throw new InvalidArgument(sprintf('%s() expects escape to be a single character or the empty string %s given', __METHOD__, $escape));
}
/**
* Enables BOM Stripping.
*
* @return static
*/
public function skipInputBOM(): self
{
$this->is_input_bom_included = false;
return $this;
}
/**
* Disables skipping Input BOM.
*
* @return static
*/
public function includeInputBOM(): self
{
$this->is_input_bom_included = true;
return $this;
}
/**
* Sets the BOM sequence to prepend the CSV on output.
*
* @return static
*/
public function setOutputBOM(string $str): self
{
$this->output_bom = $str;
return $this;
}
/**
* append a stream filter.
*
* @param null|mixed $params
*
* @throws Exception If the stream filter API can not be used
*
* @return static
*/
public function addStreamFilter(string $filtername, $params = null): self
{
if (!$this->document instanceof Stream) {
throw new UnavailableFeature('The stream filter API can not be used with a '.get_class($this->document).' instance.');
}
$this->document->appendFilter($filtername, $this->stream_filter_mode, $params);
$this->stream_filters[$filtername] = true;
$this->resetProperties();
$this->input_bom = null;
return $this;
}
}
src/CannotInsertRecord.php 0000644 00000003011 13764477326 0011630 0 ustar 00
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace League\Csv;
/**
* Thrown when a data is not added to the Csv Document.
*/
class CannotInsertRecord extends Exception
{
/**
* The record submitted for insertion.
*
* @var array
*/
protected $record;
/**
* Validator which did not validated the data.
*
* @var string
*/
protected $name = '';
/**
* Create an Exception from a record insertion into a stream.
*/
public static function triggerOnInsertion(array $record): self
{
$exception = new self('Unable to write record to the CSV document');
$exception->record = $record;
return $exception;
}
/**
* Create an Exception from a Record Validation.
*/
public static function triggerOnValidation(string $name, array $record): self
{
$exception = new self('Record validation failed');
$exception->name = $name;
$exception->record = $record;
return $exception;
}
/**
* return the validator name.
*
*/
public function getName(): string
{
return $this->name;
}
/**
* return the invalid data submitted.
*
*/
public function getRecord(): array
{
return $this->record;
}
}
src/ByteSequence.php 0000644 00000001425 13764477326 0010465 0 ustar 00
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace League\Csv;
/**
* Defines constants for common BOM sequences.
*/
interface ByteSequence
{
/**
* UTF-8 BOM sequence.
*/
const BOM_UTF8 = "\xEF\xBB\xBF";
/**
* UTF-16 BE BOM sequence.
*/
const BOM_UTF16_BE = "\xFE\xFF";
/**
* UTF-16 LE BOM sequence.
*/
const BOM_UTF16_LE = "\xFF\xFE";
/**
* UTF-32 BE BOM sequence.
*/
const BOM_UTF32_BE = "\x00\x00\xFE\xFF";
/**
* UTF-32 LE BOM sequence.
*/
const BOM_UTF32_LE = "\xFF\xFE\x00\x00";
}
src/Statement.php 0000644 00000010360 13764477326 0010033 0 ustar 00
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace League\Csv;
use ArrayIterator;
use CallbackFilterIterator;
use Iterator;
use LimitIterator;
use function array_reduce;
/**
* Criteria to filter a {@link Reader} object.
*/
class Statement
{
/**
* Callables to filter the iterator.
*
* @var callable[]
*/
protected $where = [];
/**
* Callables to sort the iterator.
*
* @var callable[]
*/
protected $order_by = [];
/**
* iterator Offset.
*
* @var int
*/
protected $offset = 0;
/**
* iterator maximum length.
*
* @var int
*/
protected $limit = -1;
/**
* Named Constructor to ease Statement instantiation.
*
* @throws Exception
*/
public static function create(callable $where = null, int $offset = 0, int $limit = -1): self
{
$stmt = new self();
if (null !== $where) {
$stmt = $stmt->where($where);
}
return $stmt->offset($offset)->limit($limit);
}
/**
* Set the Iterator filter method.
*/
public function where(callable $where): self
{
$clone = clone $this;
$clone->where[] = $where;
return $clone;
}
/**
* Set an Iterator sorting callable function.
*/
public function orderBy(callable $order_by): self
{
$clone = clone $this;
$clone->order_by[] = $order_by;
return $clone;
}
/**
* Set LimitIterator Offset.
*
* @throws Exception if the offset is lesser than 0
*/
public function offset(int $offset): self
{
if (0 > $offset) {
throw new InvalidArgument(sprintf('%s() expects the offset to be a positive integer or 0, %s given', __METHOD__, $offset));
}
if ($offset === $this->offset) {
return $this;
}
$clone = clone $this;
$clone->offset = $offset;
return $clone;
}
/**
* Set LimitIterator Count.
*
* @throws Exception if the limit is lesser than -1
*/
public function limit(int $limit): self
{
if (-1 > $limit) {
throw new InvalidArgument(sprintf('%s() expects the limit to be greater or equal to -1, %s given', __METHOD__, $limit));
}
if ($limit === $this->limit) {
return $this;
}
$clone = clone $this;
$clone->limit = $limit;
return $clone;
}
/**
* Execute the prepared Statement on the {@link Reader} object.
*
* @param string[] $header an optional header to use instead of the CSV document header
*/
public function process(TabularDataReader $tabular_data, array $header = []): TabularDataReader
{
if ([] === $header) {
$header = $tabular_data->getHeader();
}
$iterator = $tabular_data->getRecords($header);
$iterator = array_reduce($this->where, [$this, 'filter'], $iterator);
$iterator = $this->buildOrderBy($iterator);
return new ResultSet(new LimitIterator($iterator, $this->offset, $this->limit), $header);
}
/**
* Filters elements of an Iterator using a callback function.
*/
protected function filter(Iterator $iterator, callable $callable): CallbackFilterIterator
{
return new CallbackFilterIterator($iterator, $callable);
}
/**
* Sort the Iterator.
*/
protected function buildOrderBy(Iterator $iterator): Iterator
{
if ([] === $this->order_by) {
return $iterator;
}
$compare = function (array $record_a, array $record_b): int {
foreach ($this->order_by as $callable) {
if (0 !== ($cmp = $callable($record_a, $record_b))) {
return $cmp;
}
}
return $cmp ?? 0;
};
$it = new ArrayIterator();
foreach ($iterator as $offset => $value) {
$it[$offset] = $value;
}
$it->uasort($compare);
return $it;
}
}
src/TabularDataReader.php 0000644 00000006324 13764477326 0011403 0 ustar 00
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace League\Csv;
use Countable;
use Iterator;
use IteratorAggregate;
/**
* Represents a Tabular data.
*/
interface TabularDataReader extends Countable, IteratorAggregate
{
/**
* Returns the number of records contained in the tabular data structure
* excluding the header record.
*/
public function count(): int;
/**
* Returns the tabular data records as an iterator object.
*
* Each record is represented as a simple array containing strings or null values.
*
* If the CSV document has a header record then each record is combined
* to the header record and the header record is removed from the iterator.
*
* If the CSV document is inconsistent. Missing record fields are
* filled with null values while extra record fields are strip from
* the returned object.
*/
public function getIterator(): Iterator;
/**
* Returns the header associated with the tabular data.
*
* The header must contains unique string or is an empty array
* if no header was specified.
*
* @return string[]
*/
public function getHeader(): array;
/**
* Returns the tabular data records as an iterator object.
*
* Each record is represented as a simple array containing strings or null values.
*
* If the tabular data has a header record then each record is combined
* to the header record and the header record is removed from the iterator.
*
* If the tabular data is inconsistent. Missing record fields are
* filled with null values while extra record fields are strip from
* the returned object.
*
* @param string[] $header an optional header to use instead of the CSV document header
*/
public function getRecords(array $header = []): Iterator;
/**
* Returns the nth record from the tabular data.
*
* By default if no index is provided the first record of the tabular data is returned
*
* @param int $nth_record the tabular data record offset
*
* @throws Exception if argument is lesser than 0
*/
public function fetchOne(int $nth_record = 0): array;
/**
* Returns a single column from the next record of the tabular data.
*
* By default if no value is supplied the first column is fetch
*
* @param string|int $index CSV column index
*/
public function fetchColumn($index = 0): Iterator;
/**
* Returns the next key-value pairs from the tabular data (first
* column is the key, second column is the value).
*
* By default if no column index is provided:
* - the first column is used to provide the keys
* - the second column is used to provide the value
*
* @param string|int $offset_index The column index to serve as offset
* @param string|int $value_index The column index to serve as value
*/
public function fetchPairs($offset_index = 0, $value_index = 1): Iterator;
}
src/Writer.php 0000644 00000017713 13764477326 0007354 0 ustar 00
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace League\Csv;
use function array_reduce;
use function implode;
use function preg_match;
use function preg_quote;
use function str_replace;
use function strlen;
use const PHP_VERSION_ID;
use const SEEK_CUR;
use const STREAM_FILTER_WRITE;
/**
* A class to insert records into a CSV Document.
*/
class Writer extends AbstractCsv
{
/**
* callable collection to format the record before insertion.
*
* @var callable[]
*/
protected $formatters = [];
/**
* callable collection to validate the record before insertion.
*
* @var callable[]
*/
protected $validators = [];
/**
* newline character.
*
* @var string
*/
protected $newline = "\n";
/**
* Insert records count for flushing.
*
* @var int
*/
protected $flush_counter = 0;
/**
* Buffer flush threshold.
*
* @var int|null
*/
protected $flush_threshold;
/**
* {@inheritdoc}
*/
protected $stream_filter_mode = STREAM_FILTER_WRITE;
/**
* Regular expression used to detect if RFC4180 formatting is necessary.
*
* @var string
*/
protected $rfc4180_regexp;
/**
* double enclosure for RFC4180 compliance.
*
* @var string
*/
protected $rfc4180_enclosure;
/**
* {@inheritdoc}
*/
protected function resetProperties(): void
{
parent::resetProperties();
$characters = preg_quote($this->delimiter, '/').'|'.preg_quote($this->enclosure, '/');
$this->rfc4180_regexp = '/[\s|'.$characters.']/x';
$this->rfc4180_enclosure = $this->enclosure.$this->enclosure;
}
/**
* Returns the current newline sequence characters.
*/
public function getNewline(): string
{
return $this->newline;
}
/**
* Get the flush threshold.
*
* @return int|null
*/
public function getFlushThreshold()
{
return $this->flush_threshold;
}
/**
* Adds multiple records to the CSV document.
*
* @see Writer::insertOne
*/
public function insertAll(iterable $records): int
{
$bytes = 0;
foreach ($records as $record) {
$bytes += $this->insertOne($record);
}
$this->flush_counter = 0;
$this->document->fflush();
return $bytes;
}
/**
* Adds a single record to a CSV document.
*
* A record is an array that can contains scalar types values, NULL values
* or objects implementing the __toString method.
*
* @throws CannotInsertRecord If the record can not be inserted
*/
public function insertOne(array $record): int
{
$method = 'addRecord';
if (70400 > PHP_VERSION_ID && '' === $this->escape) {
$method = 'addRFC4180CompliantRecord';
}
$record = array_reduce($this->formatters, [$this, 'formatRecord'], $record);
$this->validateRecord($record);
$bytes = $this->$method($record);
if (false === $bytes || 0 >= $bytes) {
throw CannotInsertRecord::triggerOnInsertion($record);
}
return $bytes + $this->consolidate();
}
/**
* Adds a single record to a CSV Document using PHP algorithm.
*
* @see https://php.net/manual/en/function.fputcsv.php
*
* @return int|false
*/
protected function addRecord(array $record)
{
return $this->document->fputcsv($record, $this->delimiter, $this->enclosure, $this->escape);
}
/**
* Adds a single record to a CSV Document using RFC4180 algorithm.
*
* @see https://php.net/manual/en/function.fputcsv.php
* @see https://php.net/manual/en/function.fwrite.php
* @see https://tools.ietf.org/html/rfc4180
* @see http://edoceo.com/utilitas/csv-file-format
*
* String conversion is done without any check like fputcsv.
*
* - Emits E_NOTICE on Array conversion (returns the 'Array' string)
* - Throws catchable fatal error on objects that can not be converted
* - Returns resource id without notice or error (returns 'Resource id #2')
* - Converts boolean true to '1', boolean false to the empty string
* - Converts null value to the empty string
*
* Fields must be delimited with enclosures if they contains :
*
* - Embedded whitespaces
* - Embedded delimiters
* - Embedded line-breaks
* - Embedded enclosures.
*
* Embedded enclosures must be doubled.
*
* The LF character is added at the end of each record to mimic fputcsv behavior
*
* @return int|false
*/
protected function addRFC4180CompliantRecord(array $record)
{
foreach ($record as &$field) {
$field = (string) $field;
if (1 === preg_match($this->rfc4180_regexp, $field)) {
$field = $this->enclosure.str_replace($this->enclosure, $this->rfc4180_enclosure, $field).$this->enclosure;
}
}
unset($field);
return $this->document->fwrite(implode($this->delimiter, $record)."\n");
}
/**
* Format a record.
*
* The returned array must contain
* - scalar types values,
* - NULL values,
* - or objects implementing the __toString() method.
*/
protected function formatRecord(array $record, callable $formatter): array
{
return $formatter($record);
}
/**
* Validate a record.
*
* @throws CannotInsertRecord If the validation failed
*/
protected function validateRecord(array $record): void
{
foreach ($this->validators as $name => $validator) {
if (true !== $validator($record)) {
throw CannotInsertRecord::triggerOnValidation($name, $record);
}
}
}
/**
* Apply post insertion actions.
*/
protected function consolidate(): int
{
$bytes = 0;
if ("\n" !== $this->newline) {
$this->document->fseek(-1, SEEK_CUR);
/** @var int $newlineBytes */
$newlineBytes = $this->document->fwrite($this->newline, strlen($this->newline));
$bytes = $newlineBytes - 1;
}
if (null === $this->flush_threshold) {
return $bytes;
}
++$this->flush_counter;
if (0 === $this->flush_counter % $this->flush_threshold) {
$this->flush_counter = 0;
$this->document->fflush();
}
return $bytes;
}
/**
* Adds a record formatter.
*/
public function addFormatter(callable $formatter): self
{
$this->formatters[] = $formatter;
return $this;
}
/**
* Adds a record validator.
*/
public function addValidator(callable $validator, string $validator_name): self
{
$this->validators[$validator_name] = $validator;
return $this;
}
/**
* Sets the newline sequence.
*/
public function setNewline(string $newline): self
{
$this->newline = $newline;
return $this;
}
/**
* Set the flush threshold.
*
*
* @param ?int $threshold
* @throws Exception if the threshold is a integer lesser than 1
*/
public function setFlushThreshold(?int $threshold): self
{
if ($threshold === $this->flush_threshold) {
return $this;
}
if (null !== $threshold && 1 > $threshold) {
throw new InvalidArgument(__METHOD__.'() expects 1 Argument to be null or a valid integer greater or equal to 1');
}
$this->flush_threshold = $threshold;
$this->flush_counter = 0;
$this->document->fflush();
return $this;
}
}
src/functions.php 0000644 00000004750 13764477326 0010105 0 ustar 00
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace League\Csv;
use ReflectionClass;
use function array_fill_keys;
use function array_filter;
use function array_reduce;
use function array_unique;
use function count;
use function iterator_to_array;
use function rsort;
use function strlen;
use function strpos;
use const COUNT_RECURSIVE;
/**
* Returns the BOM sequence found at the start of the string.
*
* If no valid BOM sequence is found an empty string is returned
*/
function bom_match(string $str): string
{
static $list;
if (null === $list) {
$list = (new ReflectionClass(ByteSequence::class))->getConstants();
rsort($list);
}
foreach ($list as $sequence) {
if (0 === strpos($str, $sequence)) {
return $sequence;
}
}
return '';
}
/**
* Detect Delimiters usage in a {@link Reader} object.
*
* Returns a associative array where each key represents
* a submitted delimiter and each value the number CSV fields found
* when processing at most $limit CSV records with the given delimiter
*
* @param string[] $delimiters
*
* @return int[]
*/
function delimiter_detect(Reader $csv, array $delimiters, int $limit = 1): array
{
$delimiter_filter = static function (string $value): bool {
return 1 === strlen($value);
};
$record_filter = static function (array $record): bool {
return 1 < count($record);
};
$stmt = Statement::create(null, 0, $limit);
$delimiter_stats = static function (array $stats, string $delimiter) use ($csv, $stmt, $record_filter): array {
$csv->setDelimiter($delimiter);
$found_records = array_filter(
iterator_to_array($stmt->process($csv)->getRecords(), false),
$record_filter
);
$stats[$delimiter] = count($found_records, COUNT_RECURSIVE);
return $stats;
};
$current_delimiter = $csv->getDelimiter();
$current_header_offset = $csv->getHeaderOffset();
$csv->setHeaderOffset(null);
$stats = array_reduce(
array_unique(array_filter($delimiters, $delimiter_filter)),
$delimiter_stats,
array_fill_keys($delimiters, 0)
);
$csv->setHeaderOffset($current_header_offset);
$csv->setDelimiter($current_delimiter);
return $stats;
}
src/CharsetConverter.php 0000644 00000014624 13764477326 0011357 0 ustar 00
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace League\Csv;
use OutOfRangeException;
use php_user_filter;
use function array_combine;
use function array_map;
use function in_array;
use function is_numeric;
use function mb_convert_encoding;
use function mb_list_encodings;
use function preg_match;
use function sprintf;
use function stream_bucket_append;
use function stream_bucket_make_writeable;
use function stream_filter_register;
use function stream_get_filters;
use function strpos;
use function strtolower;
use function substr;
/**
* Converts resource stream or tabular data content charset.
*/
class CharsetConverter extends php_user_filter
{
const FILTERNAME = 'convert.league.csv';
/**
* the filter name used to instantiate the class with.
*
* @var string
*/
public $filtername;
/**
* @var mixed value passed to passed to stream_filter_append or stream_filter_prepend functions.
*/
public $params;
/**
* The records input encoding charset.
*
* @var string
*/
protected $input_encoding = 'UTF-8';
/**
* The records output encoding charset.
*
* @var string
*/
protected $output_encoding = 'UTF-8';
/**
* Static method to add the stream filter to a {@link AbstractCsv} object.
*/
public static function addTo(AbstractCsv $csv, string $input_encoding, string $output_encoding): AbstractCsv
{
self::register();
return $csv->addStreamFilter(self::getFiltername($input_encoding, $output_encoding));
}
/**
* Static method to register the class as a stream filter.
*/
public static function register(): void
{
$filtername = self::FILTERNAME.'.*';
if (!in_array($filtername, stream_get_filters(), true)) {
stream_filter_register($filtername, self::class);
}
}
/**
* Static method to return the stream filter filtername.
*/
public static function getFiltername(string $input_encoding, string $output_encoding): string
{
return sprintf(
'%s.%s/%s',
self::FILTERNAME,
self::filterEncoding($input_encoding),
self::filterEncoding($output_encoding)
);
}
/**
* Filter encoding charset.
*
* @throws OutOfRangeException if the charset is malformed or unsupported
*/
protected static function filterEncoding(string $encoding): string
{
static $encoding_list;
if (null === $encoding_list) {
$list = mb_list_encodings();
$encoding_list = array_combine(array_map('strtolower', $list), $list);
}
$key = strtolower($encoding);
if (isset($encoding_list[$key])) {
return $encoding_list[$key];
}
throw new OutOfRangeException(sprintf('The submitted charset %s is not supported by the mbstring extension', $encoding));
}
/**
* {@inheritdoc}
*/
public function onCreate(): bool
{
$prefix = self::FILTERNAME.'.';
if (0 !== strpos($this->filtername, $prefix)) {
return false;
}
$encodings = substr($this->filtername, strlen($prefix));
if (1 !== preg_match(',^(?[-\w]+)\/(?