├── LICENSE.md ├── bin ├── bench-read.php ├── bench-write.php └── test.php ├── composer.json ├── phpcs.xml.dist ├── res └── F.php └── src ├── Common ├── Configure.php ├── Options.php ├── PhpSpreadsheetUtils.php ├── ReadWriteString.php └── ZipUtils.php ├── Csv ├── CsvAdapter.php ├── League.php ├── Native.php ├── OpenSpout.php └── PhpSpreadsheet.php ├── SpreadCompat.php ├── SpreadInterface.php ├── Xls ├── PhpSpreadsheet.php └── XlsAdapter.php └── Xlsx ├── Native.php ├── OpenSpout.php ├── PhpSpreadsheet.php ├── Simple.php └── XlsxAdapter.php /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Thomas Portelange 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /bin/bench-read.php: -------------------------------------------------------------------------------- 1 | readFile($largeCsv)); 41 | $et = microtime(true); 42 | $diff = $et - $st; 43 | $times['csv'][$cl][] = $diff; 44 | } 45 | } 46 | 47 | foreach ($xlsx as $cl) { 48 | foreach (range(1, $reps) as $i) { 49 | /** @var \LeKoala\SpreadCompat\Xlsx\XlsxAdapter $inst */ 50 | $inst = new ($cl); 51 | 52 | $st = microtime(true); 53 | $data = iterator_to_array($inst->readFile($largeXlsx)); 54 | $et = microtime(true); 55 | $diff = $et - $st; 56 | $times['xlsx'][$cl][] = $diff; 57 | } 58 | } 59 | 60 | 61 | foreach ($times as $format => $dataFormat) { 62 | echo "Results for $format" . PHP_EOL . PHP_EOL; 63 | echo "```" . PHP_EOL; 64 | $results = []; 65 | foreach ($dataFormat as $class => $times) { 66 | $averageTime = round(array_sum($times) / count($times), 4); 67 | $results[$class] = $averageTime; 68 | } 69 | 70 | uasort($results, fn($a, $b) => $a <=> $b); 71 | foreach ($results as $class => $averageTime) { 72 | echo "$class : " . $averageTime . PHP_EOL; 73 | } 74 | echo "```" . PHP_EOL; 75 | echo PHP_EOL; 76 | } 77 | -------------------------------------------------------------------------------- /bin/bench-write.php: -------------------------------------------------------------------------------- 1 | writeFile($genData, $largeCsv); 47 | $et = microtime(true); 48 | $diff = $et - $st; 49 | $times['csv'][$cl][] = $diff; 50 | } 51 | } 52 | 53 | foreach ($xlsx as $cl) { 54 | foreach (range(1, $reps) as $i) { 55 | /** @var \LeKoala\SpreadCompat\Xlsx\XlsxAdapter $inst */ 56 | $inst = new ($cl); 57 | 58 | try { 59 | $st = microtime(true); 60 | $inst->writeFile($genData, $largeXlsx); 61 | $et = microtime(true); 62 | $diff = $et - $st; 63 | $times['xlsx'][$cl][] = $diff; 64 | } catch (Exception $e) { 65 | } 66 | } 67 | } 68 | 69 | foreach ($times as $format => $dataFormat) { 70 | echo "Results for $format" . PHP_EOL . PHP_EOL; 71 | 72 | $results = []; 73 | foreach ($dataFormat as $class => $times) { 74 | $averageTime = round(array_sum($times) / count($times), 4); 75 | $results[$class] = $averageTime; 76 | } 77 | 78 | uasort($results, fn($a, $b) => $a <=> $b); 79 | foreach ($results as $class => $averageTime) { 80 | echo "$class : " . $averageTime . PHP_EOL; 81 | } 82 | 83 | echo PHP_EOL; 84 | } 85 | -------------------------------------------------------------------------------- /bin/test.php: -------------------------------------------------------------------------------- 1 | readFile(dirname(__DIR__) . '/tests/data/header.xlsx'); 15 | // var_dump(iterator_to_array($data)); 16 | 17 | $native = new Native(); 18 | $data = $native->readFile(dirname(__DIR__) . '/tests/data/header.xlsx', assoc: true); 19 | // var_dump(iterator_to_array($data)); 20 | 21 | 22 | $books = [ 23 | ['ISBN', 'title', 'author', 'publisher', 'ctry', 'date', 'raw'], 24 | [618260307, 'The Hobbit 00', 'J. R. R. Tolkien', 'Houghton Mifflin', 'USA', '2020-05-20', "\0" . '2020-10-04 16:02:00'], 25 | [908606664, 'Slinky Malinki', 'Lynley Dodd', 'Mallinson Rendel', 'NZ', '2022-08-20', "\0" . '2020-10-04 16:02:00'] 26 | ]; 27 | $xlsx = SimpleXLSXGen::fromArray($books); 28 | // $xlsx->saveAs(__DIR__ . '/.dev/books.xlsx'); 29 | 30 | // Short style faker data 31 | 32 | 33 | function gen($max = 100_000) 34 | { 35 | $i = 0; 36 | while ($i < $max) { 37 | $i++; 38 | yield [ 39 | $i, 40 | F::d(), 41 | F::dt(), 42 | F::i(10_000, 30_000), 43 | $fn = F::fn(), 44 | $sn = F::sn(), 45 | F::em($fn . $sn), 46 | F::uw(5, 10), 47 | F::addr(), 48 | $ctry = F::ctry(), 49 | F::l($ctry), 50 | F::b(), 51 | F::pick('1', ''), 52 | F::m(), 53 | ]; 54 | } 55 | } 56 | 57 | // Yes, you can stream the response directly 58 | // Even if it has 1 million rows and that it creates a file of 97 mb... 59 | $native = new Native(); 60 | $native->output(gen(), 'books.xlsx'); 61 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lekoala/spread-compat", 3 | "description": "Easily manipulate PhpSpreadsheet, OpenSpout and League CSV", 4 | "keywords": [ 5 | "php", 6 | "spreadsheet", 7 | "excel", 8 | "csv", 9 | "package", 10 | "xls", 11 | "xlsx" 12 | ], 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "Thomas", 17 | "email": "thomas@lekoala.be" 18 | } 19 | ], 20 | "require": { 21 | "php": "^8.1" 22 | }, 23 | "require-dev": { 24 | "league/csv": "^9.10", 25 | "maennchen/zipstream-php": "^3.1", 26 | "openspout/openspout": "^4", 27 | "phpoffice/phpspreadsheet": "^1.26|^2", 28 | "phpstan/phpstan": "^2", 29 | "phpunit/phpunit": "^10|^11", 30 | "shuchkin/simplexlsx": "^1", 31 | "shuchkin/simplexlsxgen": "^1.3", 32 | "squizlabs/php_codesniffer": "^3.6" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "LeKoala\\SpreadCompat\\": "src/" 37 | } 38 | }, 39 | "minimum-stability": "dev", 40 | "prefer-stable": true, 41 | "config": { 42 | "sort-packages": true, 43 | "preferred-install": "dist" 44 | }, 45 | "scripts": { 46 | "test": [ 47 | "@phpunit", 48 | "@phpcs", 49 | "@phpstan" 50 | ], 51 | "phpunit": "phpunit", 52 | "phpunit-migrate": "phpunit --migrate-configuration", 53 | "phpunit-only": "phpunit --group=only", 54 | "phpunit-dev": "phpunit --filter=testNativeDurations", 55 | "phpcs": "phpcs", 56 | "phpstan": "phpstan analyse src/ --memory-limit=-1", 57 | "serve": "php -S localhost:8001 -t ./", 58 | "bench": [ 59 | "php ./bin/bench-read.php > ./docs/bench-read.md", 60 | "php ./bin/bench-write.php > ./docs/bench-write.md" 61 | ] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | Coding standard 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | src 18 | tests 19 | 20 | -------------------------------------------------------------------------------- /res/F.php: -------------------------------------------------------------------------------- 1 | 0) { 28 | $c--; 29 | $r[] = $arr[array_rand($arr)]; 30 | } 31 | return $r; 32 | } 33 | 34 | public static function d(): string 35 | { 36 | return date('Y-m-d', strtotime(self::pick('+', '-') . random_int(1, 365) . ' days')); 37 | } 38 | 39 | public static function t(): string 40 | { 41 | return sprintf('%02d:%02d:%02d', random_int(0, 23), random_int(0, 59), random_int(0, 59)); 42 | } 43 | 44 | public static function dt(): string 45 | { 46 | return self::d() . ' ' . self::t(); 47 | } 48 | 49 | public static function dtz(): string 50 | { 51 | return self::d() . 'T' . self::t() . 'Z'; 52 | } 53 | 54 | public static function i(int $a = -100, int $b = 100): int 55 | { 56 | return random_int($a, $b); 57 | } 58 | 59 | public static function ctry(): string 60 | { 61 | return self::C[array_rand(self::C)]; 62 | } 63 | 64 | public static function fn(): string 65 | { 66 | return self::F[array_rand(self::F)]; 67 | } 68 | 69 | public static function sn(): string 70 | { 71 | return self::S[array_rand(self::S)]; 72 | } 73 | 74 | public static function dom(): string 75 | { 76 | return self::W[array_rand(self::W)] . '.dev'; 77 | } 78 | 79 | public static function w($a = 5, $b = 10): string 80 | { 81 | return implode(' ', self::picka(self::W, random_int($a, $b))); 82 | } 83 | 84 | public static function uw($a = 5, $b = 10): string 85 | { 86 | return ucfirst(self::w($a, $b)); 87 | } 88 | 89 | public static function b(): bool 90 | { 91 | return (bool)random_int(0, 1); 92 | } 93 | 94 | public static function p(): string 95 | { 96 | return self::P[array_rand(self::P)]; 97 | } 98 | 99 | public static function addr(): string 100 | { 101 | return 'via ' . self::w(1, 1) . ', ' . self::i(1, 20) . ' - ' . self::i(1000, 9999) . ' ' . self::p(); 102 | } 103 | 104 | public static function l(string $ctry = null): string 105 | { 106 | do { 107 | $l = self::L[array_rand(self::L)]; 108 | } while ($ctry !== null && !str_contains($l, $ctry)); 109 | return $l; 110 | } 111 | 112 | public static function lg(): string 113 | { 114 | return explode('_', self::l())[0]; 115 | } 116 | 117 | public static function m(int $a = 10_000, int $b = 100_000): string 118 | { 119 | return number_format(self::i($a, $b)) . ' ' . self::pick('€', '$'); 120 | } 121 | 122 | public static function em(string $p = null): string 123 | { 124 | if ($p === null) { 125 | $p = self::fn(); 126 | } 127 | return strtolower($p) . '@' . self::dom(); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/Common/Configure.php: -------------------------------------------------------------------------------- 1 | $v) { 14 | // It's an Options class 15 | if ($v instanceof Options) { 16 | $this->configure(...get_object_vars($v)); 17 | return; 18 | } 19 | // If you passed the array directly instead of ...$opts 20 | if (is_numeric($k)) { 21 | // Proceed 22 | if (is_array($v)) { 23 | $this->configure(...$v); 24 | return; 25 | } 26 | throw new Exception("Invalid key"); 27 | } 28 | // Ignore invalid properties for this adapter 29 | if (!property_exists($this, $k)) { 30 | continue; 31 | } 32 | $this->$k = $v; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Common/Options.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class Options implements ArrayAccess 13 | { 14 | use Configure; 15 | 16 | // Common 17 | public bool $assoc = false; 18 | public ?string $adapter = null; 19 | /** 20 | * @var string[] 21 | */ 22 | public array $headers = []; 23 | 24 | // Csv only 25 | public string $separator = ","; 26 | public string $enclosure = "\""; 27 | public string $escape = "\\"; 28 | public string $eol = "\n"; 29 | public ?string $inputEncoding = null; 30 | public ?string $outputEncoding = null; 31 | public bool $bom = true; 32 | 33 | // Excel only 34 | public ?string $creator = null; 35 | public ?string $autofilter = null; 36 | public ?string $freezePane = null; 37 | public ?string $title = null; 38 | public ?string $subject = null; 39 | public ?string $keywords = null; 40 | public ?string $description = null; 41 | public ?string $category = null; 42 | public ?string $language = null; 43 | 44 | // Native xlsx 45 | public bool $stream = false; 46 | 47 | public function __construct(...$opts) 48 | { 49 | if (!empty($opts)) { 50 | $this->configure(...$opts); 51 | } 52 | } 53 | 54 | public function offsetExists(mixed $offset): bool 55 | { 56 | return property_exists($this, $offset); 57 | } 58 | 59 | public function offsetGet(mixed $offset): mixed 60 | { 61 | return $this->$offset ?? null; 62 | } 63 | 64 | public function offsetSet(mixed $offset, mixed $value): void 65 | { 66 | $this->$offset = $value; 67 | } 68 | 69 | public function offsetUnset(mixed $offset): void 70 | { 71 | $this->$offset = null; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Common/PhpSpreadsheetUtils.php: -------------------------------------------------------------------------------- 1 | getReaderClass(); 34 | /** @var \PhpOffice\PhpSpreadsheet\Reader\Xls|\PhpOffice\PhpSpreadsheet\Reader\Xlsx $reader */ 35 | $reader = new ($class); 36 | // We are only interested in cell data 37 | $reader->setReadDataOnly(true); 38 | return $reader; 39 | } 40 | 41 | protected function readSpreadsheet(Spreadsheet $spreadsheet): Generator 42 | { 43 | $headers = null; 44 | foreach ($spreadsheet->getActiveSheet()->getRowIterator() as $row) { 45 | $cellIterator = $row->getCellIterator(); 46 | $cellIterator->setIterateOnlyExistingCells(false); 47 | $data = []; 48 | foreach ($cellIterator as $cell) { 49 | $v = $cell->getValue(); 50 | $data[] = $v; 51 | } 52 | if (empty($data) || $data[0] === null) { 53 | continue; 54 | } 55 | if ($this->assoc) { 56 | if ($headers === null) { 57 | $headers = $data; 58 | continue; 59 | } 60 | $data = array_combine($headers, $data); 61 | } 62 | yield $data; 63 | } 64 | } 65 | 66 | public function readFile( 67 | string $filename, 68 | ...$opts 69 | ): Generator { 70 | $this->configure(...$opts); 71 | $spreadsheet = $this->getReader()->load($filename); 72 | yield from $this->readSpreadsheet($spreadsheet); 73 | } 74 | 75 | protected function getWriter(iterable $source): BaseWriter 76 | { 77 | $spreadsheet = new Spreadsheet(); 78 | if (!is_array($source)) { 79 | $source = iterator_to_array($source); 80 | } 81 | $sheet = $spreadsheet->getActiveSheet(); 82 | $sheet->fromArray($source); 83 | if ($this->autofilter) { 84 | $sheet->setAutoFilter($this->autofilter); 85 | } 86 | if ($this->freezePane) { 87 | $sheet->freezePane($this->freezePane); 88 | } 89 | $class = $this->getWriterClass(); 90 | /** @var \PhpOffice\PhpSpreadsheet\Writer\Xls|\PhpOffice\PhpSpreadsheet\Writer\Xlsx $writer */ 91 | $writer = new ($class)($spreadsheet); 92 | return $writer; 93 | } 94 | 95 | public function writeFile(iterable $data, string $filename, ...$opts): bool 96 | { 97 | $this->configure(...$opts); 98 | $writer = $this->getWriter($data); 99 | $writer->save($filename); 100 | return true; 101 | } 102 | 103 | public function output(iterable $data, string $filename, ...$opts): void 104 | { 105 | $this->configure(...$opts); 106 | $writer = $this->getWriter($data); 107 | 108 | SpreadCompat::outputHeaders($this->getMimetype(), $filename); 109 | ob_end_clean(); 110 | ob_start(); 111 | $writer->save('php://output'); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Common/ReadWriteString.php: -------------------------------------------------------------------------------- 1 | readFile($filename, ...$opts); 19 | unlink($filename); 20 | } 21 | 22 | public function writeString( 23 | iterable $data, 24 | ...$opts 25 | ): string { 26 | $filename = SpreadCompat::getTempFilename(); 27 | $this->writeFile($data, $filename, ...$opts); 28 | $contents = file_get_contents($filename); 29 | if (!$contents) { 30 | $contents = ""; 31 | } 32 | unlink($filename); 33 | return $contents; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Common/ZipUtils.php: -------------------------------------------------------------------------------- 1 | 'File already exists.', 15 | ZipArchive::ER_INCONS => 'Zip archive inconsistent.', 16 | ZipArchive::ER_INVAL => 'Invalid argument.', 17 | ZipArchive::ER_MEMORY => 'Malloc failure.', 18 | ZipArchive::ER_NOENT => 'No such file.', 19 | ZipArchive::ER_NOZIP => 'Not a zip archive.', 20 | ZipArchive::ER_OPEN => 'Can\'t open file.', 21 | ZipArchive::ER_READ => 'Read error.', 22 | ZipArchive::ER_SEEK => 'Seek error.', 23 | default => 'Unknown error code ' . $code . '.', 24 | }; 25 | } 26 | 27 | public static function getData(ZipArchive $zip, string $name): ?string 28 | { 29 | $idx = $zip->locateName($name); 30 | if ($idx) { 31 | $result = $zip->getFromIndex($idx); 32 | if ($result) { 33 | return $result; 34 | } 35 | } 36 | return null; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Csv/CsvAdapter.php: -------------------------------------------------------------------------------- 1 | inputEncoding) { 30 | return strtoupper($this->inputEncoding); 31 | } 32 | return null; 33 | } 34 | 35 | public function getOutputEncoding(): ?string 36 | { 37 | if ($this->outputEncoding) { 38 | return strtoupper($this->outputEncoding); 39 | } 40 | return null; 41 | } 42 | 43 | /** 44 | * @param resource|string|false $streamOrFile 45 | */ 46 | public function configureSeparator($streamOrFile = null): void 47 | { 48 | if (!$streamOrFile) { 49 | return; 50 | } 51 | if ($this->separator === "auto") { 52 | $this->separator = $this->detectSeparator($streamOrFile); 53 | } 54 | } 55 | 56 | public function getSeparator(): string 57 | { 58 | if ($this->separator === "auto") { 59 | return ","; 60 | } 61 | return $this->separator; 62 | } 63 | 64 | /** 65 | * @param resource|string $streamOrFile 66 | * @return string 67 | */ 68 | public function detectSeparator($streamOrFile = null): string 69 | { 70 | // Default separator 71 | if ($streamOrFile === null) { 72 | return ','; 73 | } 74 | 75 | $line = ""; 76 | if (is_string($streamOrFile)) { 77 | if (is_file($streamOrFile)) { 78 | $streamOrFile = fopen($streamOrFile, 'r'); 79 | } else { 80 | $line = preg_split('/\r\n|\r|\n/', $streamOrFile, 1); 81 | $line = $line[0] ?? ""; 82 | } 83 | } 84 | if (is_resource($streamOrFile)) { 85 | $line = fgets($streamOrFile); 86 | rewind($streamOrFile); 87 | } 88 | 89 | if ($line === false) { 90 | return ','; 91 | } 92 | 93 | // Remove enclosures to avoid false positives 94 | $line = preg_replace("/\"[^\"]*\"/", "", $line) ?? ""; 95 | 96 | foreach ([',', ';', '|', "\t"] as $separator) { 97 | if (str_contains($line, $separator)) { 98 | return $separator; 99 | } 100 | } 101 | 102 | return ','; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Csv/League.php: -------------------------------------------------------------------------------- 1 | configure(...$opts); 20 | $this->configureSeparator($contents); 21 | 22 | return $this->read(Reader::createFromString($contents)); 23 | } 24 | 25 | /** 26 | * @param resource $stream 27 | */ 28 | public function readStream($stream, ...$opts): Generator 29 | { 30 | $this->configure(...$opts); 31 | $this->configureSeparator($stream); 32 | 33 | return $this->read(Reader::createFromStream($stream)); 34 | } 35 | 36 | public function readFile(string $filename, ...$opts): Generator 37 | { 38 | $this->configure(...$opts); 39 | $this->configureSeparator($filename); 40 | 41 | return $this->read(Reader::createFromPath($filename)); 42 | } 43 | 44 | /** 45 | * @param iterable> $data 46 | * @param mixed ...$opts 47 | * @return string 48 | */ 49 | public function writeString(iterable $data, ...$opts): string 50 | { 51 | $this->configure(...$opts); 52 | $csv = Writer::createFromString(); 53 | $this->write($data, $csv); 54 | 55 | return $csv->toString(); 56 | } 57 | 58 | /** 59 | * @param iterable> $data 60 | * @param string $filename 61 | * @param mixed ...$opts 62 | * @return bool 63 | */ 64 | public function writeFile(iterable $data, string $filename, ...$opts): bool 65 | { 66 | try { 67 | $this->configure(...$opts); 68 | $this->write($data, Writer::createFromPath($filename, 'w')); 69 | 70 | return true; 71 | } catch (RuntimeException) { 72 | return false; 73 | } 74 | } 75 | 76 | /** 77 | * @param iterable> $data 78 | * @param string $filename 79 | * @param mixed ...$opts 80 | * @return void 81 | */ 82 | public function output(iterable $data, string $filename, ...$opts): void 83 | { 84 | $this->configure(...$opts); 85 | $csv = Writer::createFromFileObject(new SplTempFileObject()); 86 | $this->write($data, $csv); 87 | $csv->output($filename); 88 | } 89 | 90 | protected function read(Reader $csv): Generator 91 | { 92 | $this->initialize($csv); 93 | 94 | if ($this->assoc) { 95 | $csv->setHeaderOffset(0); 96 | } 97 | 98 | foreach ($csv as $record) { 99 | yield $record; 100 | } 101 | } 102 | 103 | /** 104 | * @param iterable> $data 105 | * @param Writer $csv 106 | * @return void 107 | */ 108 | protected function write(iterable $data, Writer $csv): void 109 | { 110 | $this->initialize($csv); 111 | 112 | $csv->setEndOfLine($this->eol); 113 | if ($this->bom) { 114 | $csv->setOutputBOM(match ($this->getInputEncoding()) { 115 | 'UTF-16BE' => Writer::BOM_UTF16_BE, 116 | 'UTF-16LE' => Writer::BOM_UTF16_LE, 117 | 'UTF-32BE' => Writer::BOM_UTF32_BE, 118 | 'UTF-32LE' => Writer::BOM_UTF32_LE, 119 | default => Writer::BOM_UTF8 120 | }); 121 | } 122 | 123 | if (!empty($this->headers)) { 124 | $csv->insertOne($this->headers); 125 | } 126 | 127 | $csv->insertAll($data); 128 | } 129 | 130 | /** 131 | * @throws InvalidArgument 132 | */ 133 | protected function initialize(Reader|Writer $csv): void 134 | { 135 | $csv->setDelimiter($this->getSeparator()); 136 | $csv->setEnclosure($this->enclosure); 137 | $csv->setEscape($this->escape); 138 | $defaultEncoding = mb_internal_encoding(); 139 | $inputEncoding = $this->getInputEncoding() ?? $defaultEncoding; 140 | $outputEncoding = $this->getOutputEncoding() ?? $defaultEncoding; 141 | if ($inputEncoding === $outputEncoding) { 142 | return; 143 | } 144 | 145 | if ($csv->supportsStreamFilterOnWrite() || $csv->supportsStreamFilterOnRead()) { 146 | CharsetConverter::addTo($csv, $inputEncoding, $outputEncoding); 147 | return; 148 | } 149 | 150 | $csv->addFormatter( 151 | (new CharsetConverter()) 152 | ->inputEncoding($inputEncoding) 153 | ->outputEncoding($outputEncoding) 154 | ); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/Csv/Native.php: -------------------------------------------------------------------------------- 1 | readStream($temp, ...$opts); 30 | } 31 | 32 | /** 33 | * @param resource $stream 34 | */ 35 | public function readStream($stream, ...$opts): Generator 36 | { 37 | $this->configure(...$opts); 38 | $this->configureSeparator($stream); 39 | 40 | if (fgets($stream, 4) !== self::BOM) { 41 | // bom not found - rewind pointer to start of file. 42 | rewind($stream); 43 | } 44 | $headers = null; 45 | $separator = $this->getSeparator(); 46 | 47 | while ( 48 | !feof($stream) 49 | && 50 | ($line = fgetcsv($stream, null, $separator, $this->enclosure, $this->escape)) !== false 51 | ) { 52 | if ($this->assoc) { 53 | if ($headers === null) { 54 | $headers = $line; 55 | continue; 56 | } 57 | $line = array_combine($headers, $line); 58 | } 59 | yield $line; 60 | } 61 | } 62 | 63 | public function readFile( 64 | string $filename, 65 | ...$opts 66 | ): Generator { 67 | $stream = SpreadCompat::getInputStream($filename); 68 | yield from $this->readStream($stream, ...$opts); 69 | } 70 | 71 | /** 72 | * @param resource $stream 73 | * @param iterable> $data 74 | * @return void 75 | */ 76 | protected function write($stream, iterable $data): void 77 | { 78 | if ($this->bom) { 79 | fputs($stream, self::BOM); 80 | } 81 | 82 | $separator = $this->getSeparator(); 83 | foreach ($data as $row) { 84 | $result = fputcsv($stream, $row, $separator, $this->enclosure, $this->escape, $this->eol); 85 | if ($result === false) { 86 | throw new RuntimeException("Failed to write line"); 87 | } 88 | } 89 | } 90 | 91 | /** 92 | * @param iterable> $data 93 | * @param mixed ...$opts 94 | * @return string 95 | */ 96 | public function writeString( 97 | iterable $data, 98 | ...$opts 99 | ): string { 100 | $this->configure(...$opts); 101 | 102 | $stream = SpreadCompat::getMaxMemTempStream(); 103 | $this->write($stream, $data); 104 | $contents = SpreadCompat::getStreamContents($stream); 105 | fclose($stream); 106 | return $contents; 107 | } 108 | 109 | /** 110 | * @param iterable> $data 111 | * @param string $filename 112 | * @param mixed ...$opts 113 | * @return bool 114 | */ 115 | public function writeFile( 116 | iterable $data, 117 | string $filename, 118 | ...$opts 119 | ): bool { 120 | $this->configure(...$opts); 121 | $stream = SpreadCompat::getOutputStream($filename); 122 | $this->write($stream, $data); 123 | fclose($stream); 124 | return true; 125 | } 126 | 127 | /** 128 | * @param iterable> $data 129 | * @param string $filename 130 | * @param mixed ...$opts 131 | * @return void 132 | */ 133 | public function output( 134 | iterable $data, 135 | string $filename, 136 | ...$opts 137 | ): void { 138 | $this->configure(...$opts); 139 | 140 | SpreadCompat::outputHeaders('text/csv', $filename); 141 | $stream = SpreadCompat::getOutputStream(); 142 | $this->write($stream, $data); 143 | fclose($stream); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/Csv/OpenSpout.php: -------------------------------------------------------------------------------- 1 | readFile($filename); 23 | unlink($filename); 24 | } 25 | 26 | public function readFile( 27 | string $filename, 28 | ...$opts 29 | ): Generator { 30 | $this->configure(...$opts); 31 | $this->configureSeparator($filename); 32 | $options = new \OpenSpout\Reader\CSV\Options(); 33 | 34 | $options->FIELD_DELIMITER = $this->getSeparator(); 35 | $options->FIELD_ENCLOSURE = $this->enclosure; 36 | if ($this->inputEncoding) { 37 | $options->ENCODING = $this->getInputEncoding() ?? mb_internal_encoding(); 38 | } 39 | $headers = null; 40 | //TODO: support escape 41 | 42 | $reader = new Reader($options); 43 | $reader->open($filename); 44 | foreach ($reader->getSheetIterator() as $sheet) { 45 | foreach ($sheet->getRowIterator() as $row) { 46 | $data = $row->toArray(); 47 | if ($this->assoc) { 48 | if ($headers === null) { 49 | $headers = $data; 50 | continue; 51 | } 52 | $data = array_combine($headers, $data); 53 | } 54 | yield $data; 55 | } 56 | } 57 | $reader->close(); 58 | } 59 | 60 | /** 61 | * @param resource $stream 62 | */ 63 | public function readStream($stream, ...$opts): Generator 64 | { 65 | //@link https://github.com/openspout/openspout/issues/71 66 | throw new Exception("OpenSpout doesn't support streams"); 67 | } 68 | 69 | protected function getWriter(): Writer 70 | { 71 | $options = new \OpenSpout\Writer\CSV\Options(); 72 | $options->FIELD_DELIMITER = $this->getSeparator(); 73 | $options->FIELD_ENCLOSURE = $this->enclosure; 74 | $options->SHOULD_ADD_BOM = $this->bom; 75 | $writer = new Writer($options); 76 | return $writer; 77 | } 78 | 79 | /** 80 | * @param iterable> $data 81 | * @param mixed ...$opts 82 | * @return string 83 | */ 84 | public function writeString(iterable $data, ...$opts): string 85 | { 86 | $this->configure(...$opts); 87 | $filename = SpreadCompat::getTempFilename(); 88 | $this->writeFile($data, $filename); 89 | $contents = file_get_contents($filename); 90 | if (!$contents) { 91 | $contents = ""; 92 | } 93 | unlink($filename); 94 | return $contents; 95 | } 96 | 97 | /** 98 | * @param iterable> $data 99 | * @param string $filename 100 | * @param mixed ...$opts 101 | * @return bool 102 | */ 103 | public function writeFile(iterable $data, string $filename, ...$opts): bool 104 | { 105 | $this->configure(...$opts); 106 | $writer = $this->getWriter(); 107 | 108 | //TODO: encoding? 109 | 110 | $writer->openToFile($filename); 111 | foreach ($data as $row) { 112 | $writer->addRow(Row::fromValues($row)); 113 | } 114 | $writer->close(); 115 | return true; 116 | } 117 | 118 | /** 119 | * @param iterable> $data 120 | * @param string $filename 121 | * @param mixed ...$opts 122 | * @return void 123 | */ 124 | public function output(iterable $data, string $filename, ...$opts): void 125 | { 126 | $this->configure(...$opts); 127 | $writer = $this->getWriter(); 128 | 129 | //TODO: encoding? 130 | 131 | $writer->openToBrowser($filename); 132 | foreach ($data as $row) { 133 | $writer->addRow(Row::fromValues($row)); 134 | } 135 | $writer->close(); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/Csv/PhpSpreadsheet.php: -------------------------------------------------------------------------------- 1 | inputEncoding) { 23 | $reader->setInputEncoding($this->getInputEncoding() ?? mb_internal_encoding()); 24 | } 25 | $reader->setDelimiter($this->getSeparator()); 26 | $reader->setEnclosure($this->enclosure); 27 | $reader->setEscapeCharacter($this->escape); 28 | $reader->setSheetIndex(0); 29 | // $reader->setPreserveNullString(true); 30 | // We are only interested in cell data 31 | $reader->setReadDataOnly(true); 32 | return $reader; 33 | } 34 | 35 | protected function readSpreadsheet(Spreadsheet $spreadsheet): Generator 36 | { 37 | $worksheet = $spreadsheet->getActiveSheet(); 38 | 39 | $headers = null; 40 | foreach ($worksheet->getRowIterator() as $row) { 41 | $cellIterator = $row->getCellIterator(); 42 | $cellIterator->setIterateOnlyExistingCells(false); 43 | $data = []; 44 | foreach ($cellIterator as $cell) { 45 | $data[] = $cell->getValue(); 46 | } 47 | if (empty($data) || $data[0] === null) { 48 | continue; 49 | } 50 | if ($this->assoc) { 51 | if ($headers === null) { 52 | $headers = $data; 53 | continue; 54 | } 55 | $data = array_combine($headers, $data); 56 | } 57 | yield $data; 58 | } 59 | } 60 | 61 | public function readString( 62 | string $contents, 63 | ...$opts 64 | ): Generator { 65 | $this->configure(...$opts); 66 | $spreadsheet = $this->getReader()->loadSpreadsheetFromString($contents); 67 | yield from $this->readSpreadsheet($spreadsheet); 68 | } 69 | 70 | /** 71 | * @param resource $stream 72 | */ 73 | public function readStream($stream, ...$opts): Generator 74 | { 75 | throw new Exception("PhpSpreadsheet doesn't support streams"); 76 | } 77 | 78 | public function readFile( 79 | string $filename, 80 | ...$opts 81 | ): Generator { 82 | $this->configure(...$opts); 83 | $spreadsheet = $this->getReader()->load($filename); 84 | yield from $this->readSpreadsheet($spreadsheet); 85 | } 86 | 87 | protected function getWriter(iterable $source): WriterCsv 88 | { 89 | $spreadsheet = new Spreadsheet(); 90 | if (!is_array($source)) { 91 | $source = iterator_to_array($source); 92 | } 93 | $spreadsheet->getActiveSheet()->fromArray($source); 94 | $writer = new WriterCsv($spreadsheet); 95 | if ($this->outputEncoding) { 96 | $writer->setOutputEncoding($this->getOutputEncoding() ?? mb_internal_encoding()); 97 | } 98 | $writer->setDelimiter($this->getSeparator()); 99 | $writer->setEnclosure($this->enclosure); 100 | $writer->setLineEnding($this->eol); 101 | $writer->setSheetIndex(0); 102 | $writer->setUseBOM($this->bom); 103 | $writer->setEnclosureRequired(false); // Like the default php implementation 104 | return $writer; 105 | } 106 | 107 | public function writeString(iterable $data, ...$opts): string 108 | { 109 | $this->configure(...$opts); 110 | $filename = SpreadCompat::getTempFilename(); 111 | $this->writeFile($data, $filename); 112 | $contents = file_get_contents($filename); 113 | if (!$contents) { 114 | $contents = ""; 115 | } 116 | unlink($filename); 117 | return $contents; 118 | } 119 | 120 | public function writeFile(iterable $data, string $filename, ...$opts): bool 121 | { 122 | $this->configure(...$opts); 123 | $writer = $this->getWriter($data); 124 | $writer->save($filename); 125 | return true; 126 | } 127 | 128 | public function output(iterable $data, string $filename, ...$opts): void 129 | { 130 | $this->configure(...$opts); 131 | $writer = $this->getWriter($data); 132 | 133 | SpreadCompat::outputHeaders('text/csv', $filename); 134 | ob_end_clean(); 135 | ob_start(); 136 | $writer->save('php://output'); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/SpreadCompat.php: -------------------------------------------------------------------------------- 1 | >|array $opts 112 | * @param ?string $ext 113 | * @return ?SpreadInterface 114 | */ 115 | public static function getAdapterFromOpts(array $opts, ?string $ext = null): ?SpreadInterface 116 | { 117 | $name = $opts[0]['adapter'] ?? $opts['adapter'] ?? null; 118 | if ($name === null || !is_string($name)) { 119 | return null; 120 | } 121 | // It's a full class name 122 | if (is_a($name, SpreadInterface::class, true)) { 123 | return new ($name); 124 | } 125 | if (!$ext) { 126 | $ext = self::getExtensionFromOpts($opts); 127 | } 128 | // It's a partial name, we need the extension for this 129 | if ($ext) { 130 | return self::getAdapterByName($ext, $name); 131 | } 132 | return null; 133 | } 134 | 135 | /** 136 | * @return string 137 | */ 138 | public static function getTempFilename(): string 139 | { 140 | $result = tempnam(sys_get_temp_dir(), 'S_C'); // windows only use the 3 first letters 141 | if ($result === false) { 142 | throw new Exception("Unable to create temp file"); 143 | } 144 | return $result; 145 | } 146 | 147 | public static function stringToTempFile(string $data): string 148 | { 149 | $filename = self::getTempFilename(); 150 | file_put_contents($filename, $data); 151 | return $filename; 152 | } 153 | 154 | public static function isTempFile(string $file): bool 155 | { 156 | return str_starts_with(basename($file), 'S_C'); 157 | } 158 | 159 | /** 160 | * Try to determine based on contents 161 | * Expect csv to be all printable chars 162 | */ 163 | public static function getExtensionForContent(string $contents): string 164 | { 165 | //@link https://gist.github.com/leommoore/f9e57ba2aa4bf197ebc5 166 | //50 4b 03 04 167 | $header = strtoupper(substr(bin2hex($contents), 0, 8)); 168 | if ($header === '504B0304') { 169 | $ext = self::EXT_XLSX; 170 | } else { 171 | $ext = self::EXT_CSV; 172 | } 173 | return $ext; 174 | } 175 | 176 | /** 177 | * Don't forget fclose afterwards if you don't need the stream anymore 178 | * 179 | * @param resource $stream 180 | */ 181 | public static function getStreamContents($stream): string 182 | { 183 | // Rewind to 0 before getting content from the start 184 | rewind($stream); 185 | $contents = stream_get_contents($stream); 186 | if ($contents === false) { 187 | $contents = ""; 188 | } 189 | return $contents; 190 | } 191 | 192 | /** 193 | * The memory limit of php://temp can be controlled by appending /maxmemory:NN, 194 | * where NN is the maximum amount of data to keep in memory before using a temporary file, in bytes. 195 | * 196 | * @return resource 197 | */ 198 | public static function getMaxMemTempStream() 199 | { 200 | $mb = 4; 201 | // Open for reading and writing; place the file pointer at the beginning of the file. 202 | $stream = fopen('php://temp/maxmemory:' . ($mb * 1024 * 1024), 'r+'); 203 | if (!$stream) { 204 | throw new RuntimeException("Failed to open stream"); 205 | } 206 | return $stream; 207 | } 208 | 209 | /** 210 | * @return resource 211 | */ 212 | public static function getOutputStream(string $filename = 'php://output') 213 | { 214 | // Open for writing only; place the file pointer at the beginning of the file 215 | // and truncate the file to zero length. If the file does not exist, attempt to create it. 216 | $stream = fopen($filename, 'w'); 217 | if (!$stream) { 218 | throw new RuntimeException("Failed to open stream"); 219 | } 220 | return $stream; 221 | } 222 | 223 | /** 224 | * @return resource 225 | */ 226 | public static function getInputStream(string $filename) 227 | { 228 | // Open for reading only; place the file pointer at the beginning of the file. 229 | $stream = fopen($filename, 'r'); 230 | if (!$stream) { 231 | throw new RuntimeException("Failed to open stream"); 232 | } 233 | return $stream; 234 | } 235 | 236 | public static function ensureExtension(string $filename, string $ext): string 237 | { 238 | $fileExt = pathinfo($filename, PATHINFO_EXTENSION); 239 | if ($fileExt != $ext) { 240 | $filename .= ".$ext"; 241 | } 242 | return $filename; 243 | } 244 | 245 | public static function outputHeaders(string $contentType, string $filename): void 246 | { 247 | if (headers_sent()) { 248 | throw new RuntimeException("Headers already sent"); 249 | } 250 | 251 | header('Content-Type: ' . $contentType); 252 | header( 253 | 'Content-Disposition: attachment; ' . 254 | 'filename="' . rawurlencode($filename) . '"; ' . 255 | 'filename*=UTF-8\'\'' . rawurlencode($filename) 256 | ); 257 | header('Cache-Control: max-age=0'); 258 | header('Pragma: public'); 259 | } 260 | 261 | /** 262 | * @return Generator 263 | */ 264 | public static function excelColumnRange(string $lower = 'A', string $upper = 'ZZ'): Generator 265 | { 266 | ++$upper; 267 | //@phpstan-ignore-next-line 268 | for ($i = $lower; $i !== $upper; ++$i) { 269 | //@phpstan-ignore-next-line 270 | yield $i; 271 | } 272 | } 273 | 274 | /** 275 | * Convert date for the 1900 system 276 | * @link https://docs.sheetjs.com/docs/csf/features/dates/#1904-and-1900-date-systems 277 | * @link https://gist.github.com/benjibee/3e6189e6114bc591128f1d8f5f7c9edd 278 | * @link https://github.com/PHPOffice/PhpSpreadsheet/blob/master/src/PhpSpreadsheet/Shared/Date.php#L197-L234 279 | */ 280 | public static function excelTimeToDate(string $value, ?string $format = null): string 281 | { 282 | if (!is_numeric($value)) { 283 | // Return non-numeric values as is, or throw an exception, or return an error string 284 | // Depending on desired behavior. Let's return as is for now. 285 | return $value; 286 | } 287 | 288 | $floatValue = floatval($value); 289 | 290 | // Determine output format if not provided 291 | if ($format === null) { 292 | if ($floatValue < 1 && $floatValue > 0) { 293 | $format = 'H:i:s'; 294 | } else { 295 | $format = str_contains($value, '.') ? 'Y-m-d H:i:s' : 'Y-m-d'; 296 | } 297 | } 298 | 299 | // Base date calculation: Excel day 1 is 1900-01-01, but day 0 is effectively 1899-12-31. 300 | // Excel incorrectly treats 1900 as a leap year, so day 60 is Feb 29, 1900 (wrong). 301 | // Dates BEFORE day 60 need a base of 1899-12-31, dates AFTER need 1899-12-30 to compensate. 302 | $baseDate = $floatValue < 60 && $floatValue > 0 ? '1899-12-31' : '1899-12-30'; 303 | 304 | $days = (int) floor($floatValue); 305 | 306 | $partDay = fmod($floatValue, 1); 307 | 308 | if ($days >= 0) { 309 | $days = '+' . $days; 310 | } 311 | $interval = "$days days"; 312 | 313 | $dt = new \DateTime($baseDate); 314 | $dt->modify($interval); 315 | 316 | if ($partDay > 0) { 317 | $hms = 86400 * $partDay; 318 | $microseconds = (int) round(fmod($hms, 1) * 1000000); 319 | $hms = (int) floor($hms); 320 | $hours = intdiv($hms, 3600); 321 | $hms -= $hours * 3600; 322 | $minutes = intdiv($hms, 60); 323 | $seconds = $hms % 60; 324 | $dt->setTime($hours, $minutes, $seconds, $microseconds); 325 | } 326 | 327 | // For dates in the past, we need even more adjustements 328 | // But it would be much better to store these dates in TEXT 329 | if ($days < 0) { 330 | // Check if the date is before the Gregorian calendar adoption date 331 | // This is a bit arbitray and heavily context dependent but it works on our test file 332 | if ($dt->getTimestamp() <= strtotime('1582-10-15')) { 333 | $year = (int) $dt->format('Y'); 334 | $diff = floor($year / 100) - floor($year / 400) - 2; 335 | if ($diff > 0) { 336 | $dt->modify("- $diff days"); 337 | } 338 | } 339 | } 340 | 341 | return $dt->format($format); 342 | } 343 | 344 | /** 345 | * Read properties from an excel file 346 | * @param string $filename 347 | * @return array{title:string,subject:string,creator:string,description:string,language:string,lastModifiedBy:string,keywords:string,category:string,revision:string} 348 | */ 349 | public static function excelProperties(string $filename) 350 | { 351 | $zip = new ZipArchive(); 352 | $zip->open($filename); 353 | 354 | $arr = [ 355 | 'title' => '', 356 | 'subject' => '', 357 | 'creator' => '', 358 | 'description' => '', 359 | 'language' => '', 360 | 'lastModifiedBy' => '', 361 | 'keywords' => '', 362 | 'category' => '', 363 | 'revision' => '', 364 | ]; 365 | $props = ZipUtils::getData($zip, 'docProps/core.xml'); 366 | if (!$props) { 367 | return $arr; 368 | } 369 | $matches = []; 370 | preg_match_all("/<(?:dc|cp):([\w]*)>(.*)<\/(?:dc|cp):([\w]*)>/", $props, $matches); 371 | $combine = array_combine($matches[1], $matches[2]); 372 | if ($combine) { 373 | $arr = array_merge($arr, $combine); 374 | } 375 | 376 | $zip->close(); 377 | //@phpstan-ignore-next-line 378 | return $arr; 379 | } 380 | 381 | /** 382 | * String from column index. 383 | * 384 | * @param int $index Column index (1 = A) 385 | */ 386 | public static function getLetter($index): string 387 | { 388 | foreach (self::excelColumnRange() as $letter) { 389 | $index--; 390 | if ($index <= 0) { 391 | return $letter; 392 | } 393 | } 394 | return 'A'; 395 | } 396 | 397 | public static function excelCell(int $row = 0, int $column = 0, bool $absolute = false): string 398 | { 399 | $n = $column; 400 | for ($r = ""; $n >= 0; $n = intval($n / 26) - 1) { 401 | $r = chr($n % 26 + 0x41) . $r; 402 | } 403 | if ($absolute) { 404 | return '$' . $r . '$' . ($row + 1); 405 | } 406 | return $r . ($row + 1); 407 | } 408 | 409 | /** 410 | * This function takes an associative array of options or an array 411 | * where the first argument is the associative array 412 | * @param array>|array $opts 413 | * @param string|null $fallback 414 | * @return string|null 415 | */ 416 | protected static function getExtensionFromOpts(array $opts, ?string $fallback = null): ?string 417 | { 418 | $ext = $opts[0]['extension'] ?? $opts['extension'] ?? $fallback; 419 | return is_string($ext) ? $ext : null; 420 | } 421 | 422 | public static function read(string $filename, ...$opts): Generator 423 | { 424 | $ext = self::getExtensionFromOpts($opts); 425 | $adapter = self::getAdapterFromOpts($opts, $ext); 426 | if (!$adapter) { 427 | $adapter = static::getAdapterForFile($filename, $ext); 428 | } 429 | return $adapter->readFile($filename, ...$opts); 430 | } 431 | 432 | public static function readString(string $contents, ?string $ext = null, ...$opts): Generator 433 | { 434 | $ext = self::getExtensionFromOpts($opts, $ext); 435 | if ($ext === null) { 436 | $ext = self::getExtensionForContent($contents); 437 | } 438 | $adapter = self::getAdapterFromOpts($opts, $ext); 439 | if (!$adapter) { 440 | $adapter = static::getAdapter($ext); 441 | } 442 | return $adapter->readString($contents, ...$opts); 443 | } 444 | 445 | public static function write(iterable $data, string $filename, ...$opts): bool 446 | { 447 | $ext = self::getExtensionFromOpts($opts); 448 | $adapter = self::getAdapterFromOpts($opts, $ext); 449 | if (!$adapter) { 450 | $adapter = static::getAdapterForFile($filename, $ext); 451 | } 452 | return $adapter->writeFile($data, $filename, ...$opts); 453 | } 454 | 455 | public static function writeString(iterable $data, ?string $ext = null, ...$opts): string 456 | { 457 | $ext = self::getExtensionFromOpts($opts); 458 | $adapter = self::getAdapterFromOpts($opts, $ext); 459 | if (!$adapter && !$ext) { 460 | throw new Exception("No adapter or extension specified for string"); 461 | } 462 | if (!$adapter) { 463 | $adapter = static::getAdapter($ext); 464 | } 465 | return $adapter->writeString($data, ...$opts); 466 | } 467 | 468 | public static function output( 469 | iterable $data, 470 | string $filename, 471 | ...$opts 472 | ): void { 473 | $ext = self::getExtensionFromOpts($opts); 474 | if ($ext) { 475 | $filename = self::ensureExtension($filename, $ext); 476 | } 477 | $adapter = self::getAdapterFromOpts($opts, $ext); 478 | if (!$adapter) { 479 | $adapter = static::getAdapterForFile($filename, $ext); 480 | } 481 | $adapter->output($data, $filename, ...$opts); 482 | } 483 | } 484 | -------------------------------------------------------------------------------- /src/SpreadInterface.php: -------------------------------------------------------------------------------- 1 | configure(...$opts); 29 | 30 | if (!is_file($filename)) { 31 | throw new Exception("Invalid file $filename"); 32 | } 33 | if (!is_readable($filename)) { 34 | throw new Exception("File $filename is not readable"); 35 | } 36 | 37 | $zip = new ZipArchive(); 38 | $zip->open($filename); 39 | 40 | // shared strings 41 | $ssXml = null; 42 | $ssData = ZipUtils::getData($zip, 'xl/sharedStrings.xml'); 43 | if ($ssData) { 44 | $ssXml = new SimpleXMLElement($ssData); 45 | } 46 | 47 | // styles 48 | $stylesXml = null; 49 | $numericalFormats = []; 50 | $cellFormats = []; 51 | $stylesData = ZipUtils::getData($zip, 'xl/styles.xml'); 52 | if ($stylesData) { 53 | $stylesXml = new SimpleXMLElement($stylesData); 54 | 55 | // Number formats. Built-in formats are optional and may not be included 56 | if (isset($stylesXml->numFmts)) { 57 | foreach ($stylesXml->numFmts->children() as $fmt) { 58 | $attrs = $fmt->attributes(); 59 | $numericalFormats[(string)$attrs->numFmtId] = (string)$attrs->formatCode; 60 | } 61 | } 62 | 63 | // s=id matches the cell style, then the number format from numFmts 64 | if (isset($stylesXml->cellXfs->xf)) { 65 | foreach ($stylesXml->cellXfs->xf as $v) { 66 | /** @var ?\SimpleXMLElement $numFmtId */ 67 | $numFmtId = $v->attributes()['numFmtId']; 68 | $fmtId = (string)$numFmtId; 69 | 70 | // s=x match in order so we can simply use the array index, starting with 0 71 | $cellFormat = $numericalFormats[$fmtId] ?? null; 72 | 73 | // built in styles may not be defined 74 | if ($cellFormat === null) { 75 | $cellFormat = self::getBuiltInFormatCode(intval($fmtId)); 76 | } 77 | 78 | $cellFormats[] = $cellFormat; 79 | } 80 | } 81 | } 82 | 83 | // worksheet 84 | $wsData = ZipUtils::getData($zip, 'xl/worksheets/sheet1.xml'); 85 | $zip->close(); 86 | 87 | if (!$wsData) { 88 | throw new Exception("No data"); 89 | } 90 | 91 | $columns = iterator_to_array(SpreadCompat::excelColumnRange()); 92 | $totalColumns = null; 93 | 94 | $colFormats = []; 95 | 96 | // Cache format resolution 97 | $isDateCache = []; 98 | $isDate = function (?string $excelFormatCode) use (&$isDateCache) { 99 | if (!$excelFormatCode) { 100 | return false; 101 | } 102 | if (!isset($isDateCache[$excelFormatCode])) { 103 | $result = self::isDateTimeFormatCode($excelFormatCode); 104 | $isDateCache[$excelFormatCode] = $result; 105 | } 106 | return $isDateCache[$excelFormatCode]; 107 | }; 108 | 109 | // Process data 110 | $wsXml = new SimpleXMLElement($wsData); 111 | $headers = null; 112 | $rowCount = 0; 113 | $startRow = $this->assoc ? 1 : 0; 114 | foreach ($wsXml->sheetData->children() as $row) { 115 | $rowCount++; 116 | $rowData = []; 117 | 118 | $col = 0; 119 | 120 | $isEmpty = true; 121 | 122 | // blank cells are excluded from xml 123 | foreach ($row->children() as $c) { 124 | $attrs = $c->attributes(); 125 | 126 | $t = (string)$attrs->t; // type : s (string), n (number), ... 127 | $r = (string)$attrs->r; // cell position, eg A2 128 | $s = $attrs->s; // style, eg: 1, 2 ... 129 | $v = (string)$c->v; // value 130 | 131 | $format = null; 132 | 133 | // add as many null values as missing columns 134 | $colLetter = preg_replace('/\d/', '', $r); 135 | $cellIndex = array_search($colLetter, $columns); 136 | while ($cellIndex > $col) { 137 | $rowData[] = null; 138 | $col++; 139 | } 140 | 141 | // Now we know which is the current column 142 | 143 | // it's a shared string 144 | if ($t === 's' && $ssXml) { 145 | //@phpstan-ignore-next-line 146 | $v = (string)$ssXml->si[(int)$c->v]->t ?? ''; 147 | } 148 | 149 | // it's formatted (dates may not have a "t" attribute) 150 | $excelFormat = null; 151 | if ($s !== null) { 152 | $excelFormat = $cellFormats[(int)$s] ?? null; 153 | $format = $isDate($excelFormat) ? 'date' : null; 154 | } 155 | 156 | // it's a number (and maybe a date) 157 | if ($t === 'n' && is_numeric($v)) { 158 | // Check if it's a date, see numFmts in styles.xml 159 | if ($excelFormat === null) { 160 | // If numerical format is not found, fallback to column format 161 | $format = $colFormats[$col] ?? null; 162 | } else { 163 | $format = $isDate($excelFormat) ? 'date' : 'number'; 164 | } 165 | } 166 | 167 | // Store formatting per column after first row (excluding header) 168 | if ($format !== null && $rowCount > $startRow && !isset($colFormats[$col])) { 169 | $colFormats[$col] = $format; 170 | } 171 | 172 | // Format dates 173 | if ($format === 'date') { 174 | $v = SpreadCompat::excelTimeToDate($v); 175 | } 176 | 177 | if ($v) { 178 | $isEmpty = false; 179 | } 180 | 181 | $rowData[] = $v; 182 | $col++; 183 | } 184 | 185 | // expand missing columns at the end 186 | while ($totalColumns && $col < $totalColumns) { 187 | $rowData[] = null; 188 | $col++; 189 | } 190 | 191 | if ($isEmpty) { 192 | continue; 193 | } 194 | if ($this->assoc) { 195 | if ($headers === null) { 196 | $headers = $rowData; 197 | $totalColumns = count($headers); 198 | continue; 199 | } 200 | $rowData = array_combine($headers, array_slice($rowData, 0, $totalColumns)); 201 | } else { 202 | // Assuming the first row indicates how many cells we want 203 | if ($totalColumns === null) { 204 | $totalColumns = count($rowData); 205 | } 206 | } 207 | yield $rowData; 208 | } 209 | } 210 | 211 | /** 212 | * Gets the standard format code for a built-in Open XML number format ID. 213 | * 214 | * Note: Some formats (especially dates, times, currency) are locale-dependent. 215 | * The format codes returned here are common representations (often US English based), 216 | * but the actual display might vary in spreadsheet applications based on settings. 217 | * Returns null if the ID is not a recognized built-in format ID. 218 | * 219 | * @param int $numFmtId The built-in number format ID (0-163 range roughly). 220 | * @return string|null The corresponding format code string, or null if not found. 221 | */ 222 | public static function getBuiltInFormatCode(int $numFmtId): ?string 223 | { 224 | return match ($numFmtId) { 225 | 0 => 'General', 226 | 1 => '0', 227 | 2 => '0.00', 228 | 3 => '#,##0', 229 | 4 => '#,##0.00', 230 | 5 => '$#,##0_);($#,##0)', // Often US Dollar, locale dependent 231 | 6 => '$#,##0_);[Red]($#,##0)', // Often US Dollar, locale dependent 232 | 7 => '$#,##0.00_);($#,##0.00)', // Often US Dollar, locale dependent 233 | 8 => '$#,##0.00_);[Red]($#,##0.00)', // Often US Dollar, locale dependent 234 | 9 => '0%', 235 | 10 => '0.00%', 236 | 11 => '0.00E+00', 237 | 12 => '# ?/?', 238 | 13 => '# ??/??', 239 | 14 => 'm/d/yyyy', // Locale-dependent Date 240 | 15 => 'd-mmm-yy', 241 | 16 => 'd-mmm', 242 | 17 => 'mmm-yy', 243 | 18 => 'h:mm AM/PM', // Locale-dependent Time 244 | 19 => 'h:mm:ss AM/PM', // Locale-dependent Time 245 | 20 => 'h:mm', 246 | 21 => 'h:mm:ss', 247 | 22 => 'm/d/yyyy h:mm', // Locale-dependent Date & Time 248 | 37 => '#,##0 ;(#,##0)', 249 | 38 => '#,##0 ;[Red](#,##0)', 250 | 39 => '#,##0.00;(#,##0.00)', 251 | 40 => '#,##0.00;[Red](#,##0.00)', 252 | 41 => '_(* #,##0_);_(* (#,##0);_(* "-"_);_(@_)', // Accounting 253 | 42 => '_($* #,##0_);_($* (#,##0);_($* "-"_);_(@_)', // Accounting Currency (locale dep.) 254 | 43 => '_(* #,##0.00_);_(* (#,##0.00);_(* "-"??_);_(@_)', // Accounting 255 | 44 => '_($* #,##0.00_);_($* (#,##0.00);_($* "-"??_);_(@_)', // Accounting Currency (locale dep.) 256 | 45 => 'mm:ss', 257 | 46 => '[h]:mm:ss', 258 | 47 => 'mm:ss.0', 259 | 48 => '##0.0E+0', 260 | 49 => '@', // Text format 261 | default => null, 262 | }; 263 | } 264 | 265 | /** 266 | * Is a given number format code a date/time? 267 | */ 268 | public static function isDateTimeFormatCode(string $excelFormatCode): bool 269 | { 270 | // General 271 | if (strtolower($excelFormatCode) === 'general') { 272 | return false; 273 | } 274 | // Currencies, accounting 275 | if (str_starts_with($excelFormatCode, '_') || str_starts_with($excelFormatCode, '0 ')) { 276 | return false; 277 | } 278 | // "\C\H\-00000" (Switzerland) and "\D-00000" (Germany). 279 | if (str_contains($excelFormatCode, '-00000')) { 280 | return false; 281 | } 282 | 283 | $cleanCode = str_replace(['[', ']', '.000'], '', $excelFormatCode); 284 | 285 | // Is week 286 | if ($cleanCode === 'WW') { 287 | return true; 288 | } 289 | 290 | // Is time 291 | if (str_contains($cleanCode, 'h:m')) { 292 | return true; 293 | } 294 | 295 | // Is date 296 | if (str_contains($cleanCode, 'yy') || str_contains($cleanCode, 'dd') || str_contains($cleanCode, 'mm')) { 297 | return true; 298 | } 299 | 300 | return false; 301 | } 302 | 303 | /** 304 | * @param ZipStream|ZipArchive $zip 305 | * @param iterable> $data 306 | * @return void 307 | */ 308 | protected function write($zip, iterable $data): void 309 | { 310 | $allFiles = [ 311 | '_rels/.rels' => $this->genRels(), 312 | 'docProps/app.xml' => $this->genAppXml(), 313 | 'docProps/core.xml' => $this->genCoreXml(), 314 | 'xl/styles.xml' => $this->genStyles(), 315 | 'xl/workbook.xml' => $this->genWorkbook(), 316 | // 'xl/worksheets/sheet1.xml' => $this->genWorksheet($data), 317 | 'xl/_rels/workbook.xml.rels' => $this->genWorkbookRels(), 318 | '[Content_Types].xml' => $this->genContentTypes(), 319 | ]; 320 | 321 | foreach ($allFiles as $path => $xml) { 322 | if ($zip instanceof ZipArchive) { 323 | $zip->addFromString($path, $xml); 324 | } else { 325 | $zip->addFile($path, $xml); 326 | } 327 | } 328 | 329 | // End up with worksheet 330 | $memory = $zip instanceof ZipArchive ? false : true; 331 | $stream = $this->genWorksheet($data, $memory); 332 | rewind($stream); 333 | if ($zip instanceof ZipArchive) { 334 | // $zip->addFile(SpreadCompat::getTempFilename($stream), $path); 335 | $contents = stream_get_contents($stream); 336 | if ($contents) { 337 | $zip->addFromString($contents, $path); 338 | } 339 | } else { 340 | $zip->addFileFromStream('xl/worksheets/sheet1.xml', $stream); 341 | } 342 | fclose($stream); 343 | } 344 | 345 | protected function genRels(): string 346 | { 347 | // phpcs:disable 348 | return << 350 | 351 | 352 | 353 | 354 | 355 | XML; 356 | // phpcs:enable 357 | } 358 | 359 | protected function genAppXml(): string 360 | { 361 | // phpcs:disable 362 | return << 364 | 366 | 0 367 | 368 | 369 | XML; 370 | // phpcs:enable 371 | } 372 | 373 | protected function genCoreXml(): string 374 | { 375 | $created = gmdate('Y-m-d\TH:i:s\Z'); 376 | $title = $this->title ?? ""; 377 | $subject = $this->subject ?? ""; 378 | $creator = $this->creator ?? ""; 379 | $keywords = $this->keywords ?? ""; 380 | $description = $this->description ?? ""; 381 | $category = $this->category ?? ""; 382 | $language = $this->language ?? "en-US"; 383 | 384 | // phpcs:disable 385 | return << 387 | 392 | $created 393 | $title 394 | $subject 395 | $creator 396 | $keywords 397 | $description 398 | $category 399 | $language 400 | 0 401 | 402 | XML; 403 | // phpcs:enable 404 | } 405 | 406 | protected function genStyles(): string 407 | { 408 | // phpcs:disable 409 | return <<<'XML' 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 433 | 434 | 435 | 436 | 437 | 438 | XML; 439 | // phpcs:enable 440 | } 441 | 442 | protected function genWorkbook(): string 443 | { 444 | // phpcs:disable 445 | return << 447 | 449 | 450 | 451 | 452 | 453 | 454 | XML; 455 | // phpcs:enable 456 | } 457 | 458 | /** 459 | * @param iterable> $data 460 | * @return resource 461 | */ 462 | protected function genWorksheet(iterable $data, bool $memory = true) 463 | { 464 | $tempStream = $memory ? SpreadCompat::getMaxMemTempStream() : tmpfile(); 465 | if (!$tempStream) { 466 | throw new Exception("Failed to get temp file"); 467 | } 468 | $r = 0; 469 | 470 | // Since we don't know in advance, let's have the max 471 | $MAX_ROW = 1048576; 472 | $MAX_COL = 16384; 473 | 474 | $maxCell = SpreadCompat::excelCell($MAX_ROW, $MAX_COL); 475 | 476 | $header = << 478 | 480 | 481 | 482 | 483 | 484 | 485 | XML; 486 | fwrite($tempStream, $header); 487 | 488 | $dataRow = [""]; 489 | foreach ($data as $dataRow) { 490 | $c = ""; 491 | $i = 0; 492 | foreach ($dataRow as $k => $value) { 493 | $cn = SpreadCompat::excelCell($r, $i); 494 | 495 | if (!is_scalar($value) || $value === '') { 496 | $c .= ''; 497 | } else { 498 | if ( 499 | !is_string($value) 500 | || $value == '0' 501 | || ($value[0] != '0' && ctype_digit($value)) 502 | || preg_match("/^\-?(0|[1-9][0-9]*)(\.[0-9]+)?$/", $value) 503 | ) { 504 | $c .= '' . $value . ''; //int,float,currency 505 | } else { 506 | $c .= '' . self::esc($value) . ''; 507 | } 508 | } 509 | $c .= "\r\n"; 510 | $i++; 511 | } 512 | 513 | $r++; 514 | fwrite($tempStream, "$c\r\n"); 515 | } 516 | 517 | // $totalCols = count($dataRow); 518 | // $maxLetter = SpreadCompat::getLetter($totalCols); 519 | // $maxRow = $r; 520 | 521 | $footer = << 523 | 524 | XML; 525 | fwrite($tempStream, $footer); 526 | return $tempStream; 527 | } 528 | 529 | protected function genWorkbookRels(): string 530 | { 531 | // phpcs:disable 532 | return << 534 | 535 | 536 | 537 | 538 | XML; 539 | // phpcs:enable 540 | } 541 | 542 | protected function genContentTypes(): string 543 | { 544 | // phpcs:disable 545 | return << 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | XML; 557 | // phpcs:enable 558 | } 559 | 560 | protected static function esc(string $str): string 561 | { 562 | return str_replace(['&', '<', '>', "\x00", "\x03", "\x0B"], ['&', '<', '>', '', '', ''], $str); 563 | } 564 | 565 | /** 566 | * @param iterable> $data 567 | * @param string $filename 568 | * @param mixed ...$opts 569 | * @return bool 570 | */ 571 | public function writeFile( 572 | iterable $data, 573 | string $filename, 574 | ...$opts 575 | ): bool { 576 | $this->configure(...$opts); 577 | 578 | $stream = SpreadCompat::getOutputStream($filename); 579 | 580 | if ($this->stream && class_exists(ZipStream::class)) { 581 | $zip = new ZipStream( 582 | sendHttpHeaders: false, 583 | outputStream: $stream, 584 | outputName: $filename, 585 | ); 586 | $this->write($zip, $data); 587 | $size = $zip->finish(); 588 | } else { 589 | $mode = ZipArchive::CREATE; 590 | if (is_file($filename)) { 591 | $mode = ZipArchive::OVERWRITE; 592 | } 593 | $zip = new ZipArchive(); 594 | $result = $zip->open($filename, $mode); 595 | if ($result !== true) { 596 | throw new Exception("Failed to open zip archive, code: " . ZipUtils::zipError($result)); 597 | } 598 | $this->write($zip, $data); 599 | if (!SpreadCompat::isTempFile($filename)) { 600 | $zip->close(); 601 | } 602 | } 603 | 604 | return fclose($stream); 605 | } 606 | 607 | /** 608 | * @param iterable> $data 609 | * @param string $filename 610 | * @param mixed ...$opts 611 | * @return void 612 | */ 613 | public function output( 614 | iterable $data, 615 | string $filename, 616 | ...$opts 617 | ): void { 618 | $this->configure(...$opts); 619 | 620 | $mime = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; 621 | if ($this->stream && class_exists(ZipStream::class)) { 622 | $zip = new ZipStream( 623 | contentType: $mime, 624 | sendHttpHeaders: true, 625 | outputName: $filename, 626 | ); 627 | $this->write($zip, $data); 628 | $size = $zip->finish(); 629 | } else { 630 | SpreadCompat::outputHeaders($mime, $filename); 631 | 632 | $tempFilename = SpreadCompat::getTempFilename(); 633 | if (is_file($tempFilename)) { 634 | unlink($tempFilename); // ZipArchive needs no file 635 | } 636 | $zip = new ZipArchive(); 637 | $zip->open($tempFilename, ZipArchive::CREATE); 638 | $this->write($zip, $data); 639 | $zip->close(); 640 | readfile($tempFilename); 641 | exit(); 642 | } 643 | } 644 | } 645 | -------------------------------------------------------------------------------- /src/Xlsx/OpenSpout.php: -------------------------------------------------------------------------------- 1 | configure(...$opts); 23 | $options = new \OpenSpout\Reader\XLSX\Options(); 24 | 25 | $headers = []; 26 | $reader = new Reader($options); 27 | // If you have a validation issue saying "Validation failed: no DTD found !" maybe your php version is too old 28 | $reader->open($filename); 29 | foreach ($reader->getSheetIterator() as $sheet) { 30 | foreach ($sheet->getRowIterator() as $row) { 31 | $data = $row->toArray(); 32 | if ($this->assoc) { 33 | if (empty($headers)) { 34 | $headers = $data; 35 | continue; 36 | } 37 | $data = array_combine($headers, $data); 38 | } 39 | yield $data; 40 | } 41 | } 42 | 43 | $reader->close(); 44 | } 45 | 46 | protected function getWriter(): Writer 47 | { 48 | $options = new \OpenSpout\Writer\XLSX\Options(); 49 | 50 | if (method_exists($options, 'getProperties') && class_exists(Properties::class)) { 51 | $options->setProperties(new Properties( 52 | creator: $this->creator 53 | )); 54 | } 55 | $writer = new Writer($options); 56 | // @link https://github.com/openspout/openspout/issues/286 57 | if ($this->creator && method_exists($writer, 'setCreator')) { 58 | $writer->setCreator($this->creator); 59 | } 60 | return $writer; 61 | } 62 | 63 | /** 64 | * Call this after opening 65 | * 66 | * @param Writer $writer 67 | * @return void 68 | */ 69 | protected function setSheetView(Writer $writer) 70 | { 71 | if ($this->freezePane) { 72 | $sheetView = new SheetView(); 73 | $row = (int)substr($this->freezePane, 1, 1); 74 | if ($row > 0) { 75 | $sheetView->setFreezeRow($row); 76 | $sheetView->setFreezeColumn(substr($this->freezePane, 0, 1)); 77 | } 78 | $writer->getCurrentSheet()->setSheetView($sheetView); 79 | } 80 | if ($this->autofilter) { 81 | $c = $this->autofilterCoords(); 82 | $autoFilter = new AutoFilter($c[0], $c[1], $c[2], $c[3]); 83 | $writer->getCurrentSheet()->setAutoFilter($autoFilter); 84 | } 85 | } 86 | 87 | /** 88 | * @return array{int<0,25>,positive-int,int<0,25>,positive-int} 89 | */ 90 | public function autofilterCoords(): array 91 | { 92 | $parts = explode(":", $this->autofilter ?? ""); 93 | $from = $parts[0]; 94 | $to = $parts[1]; 95 | 96 | $letters = range('A', 'Z'); 97 | 98 | $fromColumnIndex = (int)array_search(substr($from, 0, 1), $letters, true); 99 | $fromRow = (int)substr($from, 1, 1); 100 | $toColumnIndex = (int)array_search(substr($to, 0, 1), $letters, true); 101 | $toRow = (int)substr($to, 1, 1); 102 | 103 | assert($fromRow > 0 && $toRow > 0); 104 | 105 | return [ 106 | $fromColumnIndex, 107 | $fromRow, 108 | $toColumnIndex, 109 | $toRow, 110 | ]; 111 | } 112 | 113 | /** 114 | * @param iterable> $data 115 | * @param string $filename 116 | * @param mixed ...$opts 117 | * @return bool 118 | */ 119 | public function writeFile(iterable $data, string $filename, ...$opts): bool 120 | { 121 | $this->configure(...$opts); 122 | $writer = $this->getWriter(); 123 | 124 | //TODO: encoding? 125 | 126 | $writer->openToFile($filename); 127 | $this->setSheetView($writer); 128 | foreach ($data as $row) { 129 | $writer->addRow(Row::fromValues($row)); 130 | } 131 | $writer->close(); 132 | return true; 133 | } 134 | 135 | /** 136 | * @param iterable> $data 137 | * @param string $filename 138 | * @param mixed ...$opts 139 | * @return void 140 | */ 141 | public function output(iterable $data, string $filename, ...$opts): void 142 | { 143 | $this->configure(...$opts); 144 | $writer = $this->getWriter(); 145 | 146 | //TODO: encoding? 147 | 148 | $writer->openToBrowser($filename); 149 | $this->setSheetView($writer); 150 | foreach ($data as $row) { 151 | $writer->addRow(Row::fromValues($row)); 152 | } 153 | $writer->close(); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/Xlsx/PhpSpreadsheet.php: -------------------------------------------------------------------------------- 1 | configure(...$opts); 24 | 25 | /** @var SimpleXLSX|null $xlsx */ 26 | $xlsx = SimpleXLSX::parse($filename); 27 | if (!$xlsx) { 28 | $err = SimpleXLSX::parseError(); 29 | if (!is_string($err)) { 30 | $err = "unknown error"; 31 | } 32 | throw new RuntimeException("Parse error: $err"); 33 | } 34 | $headers = null; 35 | 36 | /** @var iterable> $rows */ 37 | $rows = $xlsx->readRows(); 38 | foreach ($rows as $r) { 39 | if (empty($r) || $r[0] === "") { 40 | continue; 41 | } 42 | if ($this->assoc) { 43 | if ($headers === null) { 44 | $headers = $r; 45 | continue; 46 | } 47 | $r = array_combine($headers, $r); 48 | } 49 | yield $r; 50 | } 51 | } 52 | 53 | protected function getWriter(iterable $data): SimpleXLSXGen 54 | { 55 | if (!is_array($data)) { 56 | $data = iterator_to_array($data); 57 | } 58 | 59 | /** @var SimpleXLSXGen|null $xlsx */ 60 | $xlsx = SimpleXLSXGen::fromArray($data); 61 | if (!$xlsx) { 62 | throw new RuntimeException("Read from array error"); 63 | } 64 | if ($this->creator) { 65 | $xlsx->setAuthor($this->creator); 66 | } 67 | if ($this->autofilter) { 68 | $xlsx->autoFilter($this->autofilter); 69 | } 70 | if ($this->freezePane) { 71 | $xlsx->freezePanes($this->freezePane); 72 | } 73 | return $xlsx; 74 | } 75 | 76 | /** 77 | * @param iterable> $data 78 | * @param string $filename 79 | * @param mixed ...$opts 80 | * @return bool 81 | */ 82 | public function writeFile( 83 | iterable $data, 84 | string $filename, 85 | ...$opts 86 | ): bool { 87 | $this->configure(...$opts); 88 | $xlsx = $this->getWriter($data); 89 | $xlsx->saveAs($filename); 90 | return true; 91 | } 92 | 93 | /** 94 | * @param iterable> $data 95 | * @param string $filename 96 | * @param mixed ...$opts 97 | * @return void 98 | */ 99 | public function output( 100 | iterable $data, 101 | string $filename, 102 | ...$opts 103 | ): void { 104 | $this->configure(...$opts); 105 | $xlsx = $this->getWriter($data); 106 | $xlsx->downloadAs($filename); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Xlsx/XlsxAdapter.php: -------------------------------------------------------------------------------- 1 |