Merge pull request #4827 from kemo/perf/csv-streaming-encoding

Experimental Stream encoding conversion in CSV reader to reduce peak memory
This commit is contained in:
oleibman
2026-06-07 14:01:26 +00:00
committed by GitHub
3 changed files with 1020 additions and 0 deletions
+165
View File
@@ -0,0 +1,165 @@
<?php
namespace PhpOffice\PhpSpreadsheetBenchmarks;
use PhpOffice\PhpSpreadsheet\Reader\Csv;
use PhpOffice\PhpSpreadsheet\Reader\Exception as ReaderException;
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
class CsvChunk extends Csv
{
/**
* Size of each chunk when streaming encoding conversion.
* Aligned to a multiple of 4 so UTF-16/UTF-32 character
* boundaries are never split.
*/
private const CHUNK_SIZE = 65536;
protected int $chunkSize = self::CHUNK_SIZE;
public function setChunkSize(int $chunkSize): void
{
$this->chunkSize = $chunkSize;
}
/**
* Convert file encoding to UTF-8 using chunked streaming to avoid
* loading the entire file into memory at once.
*/
protected function convertNonUtf8(string $filename): void
{
$sourceHandle = null;
$encoding = strtoupper($this->inputEncoding);
if ($encoding === 'UTF-16' || $encoding === 'UCS-2') {
$sourceHandle = fopen($filename, 'rb');
if ($sourceHandle === false) {
$sourceHandle = null;
} else {
$first2 = (string) fread($sourceHandle, self::UTF16BE_BOM_LEN);
if ($first2 === self::UTF16BE_BOM) {
$encoding .= 'BE';
} elseif ($first2 === self::UTF16LE_BOM) {
$encoding .= 'LE';
} else {
$encoding .= 'BE';
fclose($sourceHandle);
$sourceHandle = null;
}
}
} elseif ($encoding === 'UTF-32' || $encoding === 'UCS-4') {
$sourceHandle = fopen($filename, 'rb');
if ($sourceHandle === false) {
$sourceHandle = null;
} else {
$first2 = (string) fread($sourceHandle, self::UTF32BE_BOM_LEN);
if ($first2 === self::UTF32BE_BOM) {
$encoding .= 'BE';
} elseif ($first2 === self::UTF32LE_BOM) {
$encoding .= 'LE';
} else {
$encoding .= 'BE';
fclose($sourceHandle);
$sourceHandle = null;
}
}
}
if (str_starts_with($encoding, 'UTF-7')) {
parent::convertNonUtf8($filename);
return;
}
fclose($this->fileHandle);
if ($sourceHandle === null) {
$sourceHandle = fopen($filename, 'rb');
}
// Using php://temp instead of php://memory: spills to disk when data
// exceeds 2MB, reducing peak memory for large files.
$outputHandle = fopen('php://temp', 'r+b');
if ($sourceHandle === false || $outputHandle === false) {
// @codeCoverageIgnoreStart
if ($sourceHandle !== false) {
fclose($sourceHandle);
}
if ($outputHandle !== false) {
fclose($outputHandle);
}
throw new ReaderException("Failed to open file for encoding conversion: {$filename}");
// @codeCoverageIgnoreEnd
}
if ($encoding === 'UTF-16BE') {
$checkdigit = -2;
} elseif ($encoding === 'UTF-16LE') {
$checkdigit = -1;
} else {
$checkdigit = 0;
}
$charWidth = $this->encodingCharWidth($encoding);
// Ensure chunk size is aligned to character width
$chunkSize = $this->chunkSize - ($this->chunkSize % $charWidth);
$leftover = '';
while (!feof($sourceHandle)) {
$rawChunk = fread($sourceHandle, max(1, $chunkSize));
if ($rawChunk === false || $rawChunk === '') {
break; // @codeCoverageIgnore
}
if ($checkdigit !== 0) {
$last1 = substr($rawChunk, $checkdigit, 1);
if (in_array($last1, ["\xd8", "\xd9", "\xda", "\xdb"], true)) {
$newChunk = fread($sourceHandle, 2);
if ($newChunk === false) {
break; // @codeCoverageIgnore
}
$rawChunk .= $newChunk;
}
}
$chunk = $leftover . $rawChunk;
$leftover = '';
if ($charWidth > 1) {
// For fixed-width multi-byte encodings (UTF-16, UTF-32),
// ensure we don't split in the middle of a character
$remainder = strlen($chunk) % $charWidth;
if ($remainder !== 0) {
$leftover = substr($chunk, -$remainder);
$chunk = substr($chunk, 0, -$remainder);
}
}
// For variable-width encodings (e.g. UTF-8 source, though
// this path is for non-UTF-8), and single-byte encodings
// (ISO-8859-*, CP1252), no boundary adjustment needed.
// Single-byte encodings have 1:1 byte-to-character mapping.
if ($chunk !== '') {
$converted = StringHelper::convertEncoding($chunk, 'UTF-8', $encoding);
fwrite($outputHandle, $converted);
}
}
// Flush any remaining bytes (incomplete multi-byte chars will throw)
if ($leftover !== '') {
$converted = StringHelper::convertEncoding($leftover, 'UTF-8', $encoding);
fwrite($outputHandle, $converted); // @codeCoverageIgnore
}
fclose($sourceHandle);
$this->fileHandle = $outputHandle;
$this->skipBOM();
}
/**
* Return the byte width of a single character in the given encoding.
* Returns 1 for variable-width or single-byte encodings.
*/
private function encodingCharWidth(string $encoding): int
{
return match ($encoding) {
'UTF-32BE', 'UTF-32LE', 'UCS-4BE', 'UCS-4LE' => 4, // UTF-32 and UCS-4 are given BE/LE suffix above
'UTF-16BE', 'UTF-16LE', 'UCS-2BE', 'UCS-2LE' => 2, // UTF-16 and UCS-2 are given BE/LE suffix above
default => 1,
};
}
}
@@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
namespace PhpOffice\PhpSpreadsheetBenchmarks;
use PhpOffice\PhpSpreadsheet\Reader\Csv;
use PhpOffice\PhpSpreadsheetTests\Reader\Csv\CsvIconv2;
use PHPUnit\Framework\TestCase;
#[\PHPUnit\Framework\Attributes\Group('benchmark')]
/**
* @codeCoverageIgnore
*/
class CsvStreamingEncodingBenchmarkTest extends TestCase
{
private string $whichCsv = 'CsvChunk';
//private string $whichCsv = 'CsvIconv2';
//private string $whichCsv = 'Csv';
/** @var string[] */
private array $tempFiles = [];
protected function tearDown(): void
{
foreach ($this->tempFiles as $file) {
if (file_exists($file)) {
unlink($file);
}
}
$this->tempFiles = [];
}
/**
* Generate a CSV file in ISO-8859-1 encoding with accented characters.
*/
private function generateIso88591Csv(int $rows, int $cols = 10): string
{
$tempFile = tempnam(sys_get_temp_dir(), 'csv_bench_iso_') . '.csv';
$this->tempFiles[] = $tempFile;
$handle = fopen($tempFile, 'wb');
self::assertNotFalse($handle);
// Accented characters in ISO-8859-1: é(0xE9), ö(0xF6), ü(0xFC), ñ(0xF1), à(0xE0), ç(0xE7)
$specialChars = ["\xE9", "\xF6", "\xFC", "\xF1", "\xE0", "\xE7"];
for ($r = 0; $r < $rows; ++$r) {
$fields = [];
for ($c = 0; $c < $cols; ++$c) {
$accent = $specialChars[($r + $c) % count($specialChars)];
$fields[] = sprintf('Cell_%d_%d_%s_data_with_special_chars_%s', $r, $c, $accent, $accent);
}
fwrite($handle, implode(',', $fields) . "\n");
}
fclose($handle);
return $tempFile;
}
/**
* Generate a CSV file in UTF-16LE encoding with BOM and accented characters.
*/
private function generateUtf16LeCsv(int $rows, int $cols = 10): string
{
$tempFile = tempnam(sys_get_temp_dir(), 'csv_bench_u16_') . '.csv';
$this->tempFiles[] = $tempFile;
$handle = fopen($tempFile, 'wb');
self::assertNotFalse($handle);
// Write UTF-16LE BOM
fwrite($handle, "\xFF\xFE");
$specialChars = ['é', 'ö', 'ü', 'ñ', 'à', 'ç'];
for ($r = 0; $r < $rows; ++$r) {
$fields = [];
for ($c = 0; $c < $cols; ++$c) {
$accent = $specialChars[($r + $c) % count($specialChars)];
$fields[] = sprintf('Cell_%d_%d_%s_data_with_special_chars_%s', $r, $c, $accent, $accent);
}
$line = implode(',', $fields) . "\n";
// Convert UTF-8 line to UTF-16LE
$converted = mb_convert_encoding($line, 'UTF-16LE', 'UTF-8');
fwrite($handle, $converted);
}
fclose($handle);
return $tempFile;
}
/**
* Read a CSV file with the given encoding and return timing/memory stats.
*
* @return array{time_ms: float, memory_used_mb: float, rows: int}
*/
private function benchmarkRead(string $filename, string $encoding): array
{
// Force garbage collection before measurement
gc_collect_cycles();
$memoryBefore = memory_get_usage(true);
switch ($this->whichCsv) {
case 'CsvChunk':
fwrite(STDERR, "Using CsvChunk\n");
$reader = new CsvChunk();
break;
case 'CsvIconv2':
fwrite(STDERR, "Using CsvIconv2\n");
$reader = new CsvIconv2();
break;
default:
fwrite(STDERR, "Using Csv\n");
$reader = new Csv();
}
$reader->setInputEncoding($encoding);
$startTime = hrtime(true);
$spreadsheet = $reader->load($filename);
$endTime = hrtime(true);
$memoryAfter = memory_get_usage(true);
$sheet = $spreadsheet->getActiveSheet();
$rows = $sheet->getHighestRow();
$spreadsheet->disconnectWorksheets();
return [
'time_ms' => ($endTime - $startTime) / 1_000_000,
'memory_used_mb' => ($memoryAfter - $memoryBefore) / 1024 / 1024,
'rows' => $rows,
];
}
public function testStreamingEncodingIso88591(): void
{
$filename = $this->generateIso88591Csv(5000);
$fileSize = filesize($filename);
$result = $this->benchmarkRead($filename, 'ISO-8859-1');
fwrite(STDERR, "\n");
fwrite(STDERR, "=== ISO-8859-1 Streaming Encoding Benchmark ===\n");
fwrite(STDERR, sprintf("File size: %.2f MB\n", $fileSize / 1024 / 1024));
fwrite(STDERR, sprintf("Rows read: %d\n", $result['rows']));
fwrite(STDERR, sprintf("Time: %.2f ms\n", $result['time_ms']));
fwrite(STDERR, sprintf("Memory used: %.2f MB\n", $result['memory_used_mb']));
self::assertSame(5000, $result['rows']);
}
public function testStreamingEncodingUtf16Le(): void
{
$filename = $this->generateUtf16LeCsv(5000);
$fileSize = filesize($filename);
$result = $this->benchmarkRead($filename, 'UTF-16LE');
fwrite(STDERR, "\n");
fwrite(STDERR, "=== UTF-16LE Streaming Encoding Benchmark ===\n");
fwrite(STDERR, sprintf("File size: %.2f MB\n", $fileSize / 1024 / 1024));
fwrite(STDERR, sprintf("Rows read: %d\n", $result['rows']));
fwrite(STDERR, sprintf("Time: %.2f ms\n", $result['time_ms']));
fwrite(STDERR, sprintf("Memory used: %.2f MB\n", $result['memory_used_mb']));
self::assertSame(5000, $result['rows']);
}
public function testMemoryScalingWithFileSize(): void
{
// Generate small and large ISO-8859-1 CSV files
$smallFile = $this->generateIso88591Csv(1000);
$largeFile = $this->generateIso88591Csv(5000);
$smallFileSize = filesize($smallFile);
$largeFileSize = filesize($largeFile);
$smallResult = $this->benchmarkRead($smallFile, 'ISO-8859-1');
$largeResult = $this->benchmarkRead($largeFile, 'ISO-8859-1');
$fileSizeRatio = $largeFileSize / $smallFileSize;
$smallMemory = $smallResult['memory_used_mb'];
$memoryRatio = 0;
if ($smallMemory > 0) {
$memoryRatio = $largeResult['memory_used_mb'] / $smallMemory;
}
fwrite(STDERR, "\n");
fwrite(STDERR, "=== Memory Scaling Comparison (ISO-8859-1) ===\n");
fwrite(STDERR, sprintf("Small file (1000 rows): %.2f MB file, %.2f MB memory used, %.2f ms\n", $smallFileSize / 1024 / 1024, $smallResult['memory_used_mb'], $smallResult['time_ms']));
fwrite(STDERR, sprintf("Large file (5000 rows): %.2f MB file, %.2f MB memory used, %.2f ms\n", $largeFileSize / 1024 / 1024, $largeResult['memory_used_mb'], $largeResult['time_ms']));
fwrite(STDERR, sprintf("File size ratio (large/small): %.2fx\n", $fileSizeRatio));
fwrite(STDERR, sprintf("Memory ratio (large/small): %.2fx\n", $memoryRatio));
fwrite(STDERR, sprintf("Streaming keeps memory sub-linear: %s\n", $memoryRatio < $fileSizeRatio ? 'YES' : 'NO'));
// With streaming, memory should not scale linearly with file size.
// The encoding conversion itself uses constant memory (CHUNK_SIZE),
// though the spreadsheet object will still grow with row count.
// We verify memory ratio is less than file size ratio.
self::assertLessThan(
$fileSizeRatio,
$memoryRatio,
'Peak memory should grow sub-linearly relative to file size due to streaming encoding conversion'
);
// Also generate small and large UTF-16LE files
$smallUtf16 = $this->generateUtf16LeCsv(1000);
$largeUtf16 = $this->generateUtf16LeCsv(5000);
$smallUtf16Size = filesize($smallUtf16);
$largeUtf16Size = filesize($largeUtf16);
$smallUtf16Result = $this->benchmarkRead($smallUtf16, 'UTF-16LE');
$largeUtf16Result = $this->benchmarkRead($largeUtf16, 'UTF-16LE');
$utf16FileSizeRatio = $largeUtf16Size / $smallUtf16Size;
$smallMemory = $smallUtf16Result['memory_used_mb'];
$utf16MemoryRatio = 0;
if ($smallMemory > 0) {
$utf16MemoryRatio = $largeUtf16Result['memory_used_mb'] / $smallMemory;
}
fwrite(STDERR, "\n");
fwrite(STDERR, "=== Memory Scaling Comparison (UTF-16LE) ===\n");
fwrite(STDERR, sprintf("Small file (1000 rows): %.2f MB file, %.2f MB memory used, %.2f ms\n", $smallUtf16Size / 1024 / 1024, $smallUtf16Result['memory_used_mb'], $smallUtf16Result['time_ms']));
fwrite(STDERR, sprintf("Large file (5000 rows): %.2f MB file, %.2f MB memory used, %.2f ms\n", $largeUtf16Size / 1024 / 1024, $largeUtf16Result['memory_used_mb'], $largeUtf16Result['time_ms']));
fwrite(STDERR, sprintf("File size ratio (large/small): %.2fx\n", $utf16FileSizeRatio));
fwrite(STDERR, sprintf("Memory ratio (large/small): %.2fx\n", $utf16MemoryRatio));
fwrite(STDERR, sprintf("Streaming keeps memory sub-linear: %s\n", $utf16MemoryRatio < $utf16FileSizeRatio ? 'YES' : 'NO'));
self::assertLessThan(
$utf16FileSizeRatio,
$utf16MemoryRatio,
'Peak memory should grow sub-linearly relative to file size due to streaming encoding conversion (UTF-16LE)'
);
}
}
@@ -0,0 +1,612 @@
<?php
declare(strict_types=1);
namespace PhpOffice\PhpSpreadsheetBenchmarks;
use Exception;
use PhpOffice\PhpSpreadsheet\Reader\Csv;
use PhpOffice\PhpSpreadsheet\Shared\File;
//use PhpOffice\PhpSpreadsheetBenchmarks\CsvChunk;
use PhpOffice\PhpSpreadsheetTests\Reader\Csv\CsvIconv2;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* Tests for the streaming/chunked encoding conversion in the CSV reader.
*
* Verifies that encoding conversion produces correct results when
* processing files in chunks rather than loading them entirely into memory.
*/
class CsvStreamingEncodingTest extends TestCase
{
private string $whichCsv = 'CsvChunk';
//private string $whichCsv = 'CsvIconv2';
//private string $whichCsv = 'Csv';
private string $tempFile = '';
protected function tearDown(): void
{
if ($this->tempFile !== '') {
unlink($this->tempFile);
$this->tempFile = '';
}
}
private function newCsv(): Csv
{
if ($this->whichCsv === 'CsvChunk') {
return new CsvChunk();
}
if ($this->whichCsv === 'CsvIconv2') {
return new CsvIconv2();
}
return new Csv();
}
/**
* Test that existing non-UTF-8 CSV files are still read correctly
* with the streaming approach.
*/
#[DataProvider('providerExistingEncodings')]
public function testExistingNonUtf8FilesReadCorrectly(string $filename, string $encoding): void
{
$reader = $this->newCsv();
$reader->setInputEncoding($encoding);
$spreadsheet = $reader->load($filename);
$sheet = $spreadsheet->getActiveSheet();
self::assertEquals("\u{00C5}", $sheet->getCell('A1')->getValue(), 'Å character should be preserved after streaming encoding conversion');
$spreadsheet->disconnectWorksheets();
}
public static function providerExistingEncodings(): array
{
return [
'ISO-8859-1' => ['tests/data/Reader/CSV/encoding.iso88591.csv', 'ISO-8859-1'],
'UTF-16BE' => ['tests/data/Reader/CSV/encoding.utf16be.csv', 'UTF-16BE'],
'UTF-16LE' => ['tests/data/Reader/CSV/encoding.utf16le.csv', 'UTF-16LE'],
'UTF-32BE' => ['tests/data/Reader/CSV/encoding.utf32be.csv', 'UTF-32BE'],
'UTF-32LE' => ['tests/data/Reader/CSV/encoding.utf32le.csv', 'UTF-32LE'],
];
}
/**
* Test that UTF-8 files (no conversion needed) are unaffected by the changes.
*/
public function testUtf8FileUnaffected(): void
{
$reader = $this->newCsv();
$reader->setInputEncoding('UTF-8');
$spreadsheet = $reader->load('tests/data/Reader/CSV/encoding.utf8.csv');
$sheet = $spreadsheet->getActiveSheet();
self::assertEquals("\u{00C5}", $sheet->getCell('A1')->getValue());
$val = $sheet->getCell('B1')->getValue();
self::assertIsScalar($val);
self::assertEquals(1, (int) $val);
$spreadsheet->disconnectWorksheets();
}
/**
* Test that UTF-8 BOM files work correctly without conversion.
*/
public function testUtf8BomFileUnaffected(): void
{
$reader = $this->newCsv();
$reader->setInputEncoding('UTF-8');
$spreadsheet = $reader->load('tests/data/Reader/CSV/encoding.utf8bom.csv');
$sheet = $spreadsheet->getActiveSheet();
self::assertEquals("\u{00C5}", $sheet->getCell('A1')->getValue());
$spreadsheet->disconnectWorksheets();
}
public function testChunkBrokenUtf16BE(): void
{
$array = [
"\xfe\xff", // bom
"\xd8\x01\xdc\x00", // osmanya 𐐀
"\x00\x20", // blank
"\x00\x68", // h
"\x00\x65", // e
"\x00\x6c", // l
"\x00\x6c", // l
"\x00\x6f", // o
"\x00\x20", // blank
"\xd8\x01\xdc\x01", // osmanya 𐐁
"\x00\x0a", // newline
];
$chars = implode('', $array);
$filename = $this->tempFile = File::temporaryFileName() . '.csv';
$fho = fopen($filename, 'wb');
self::assertNotFalse($fho);
fwrite($fho, $chars);
fclose($fho);
$reader = $this->newCsv();
$reader->setInputEncoding('UTF-16');
$reader->setDelimiter(',');
if (method_exists($reader, 'setChunkSize')) {
$reader->setChunkSize(2);
}
$spreadsheet = $reader->load($filename);
$sheet = $spreadsheet->getActiveSheet();
self::assertSame('𐐀 hello 𐐁', $sheet->getCell('A1')->getValue());
$spreadsheet->disconnectWorksheets();
}
public function testChunkBrokenUtf16LE(): void
{
$array = [
"\xff\xfe", // bom
"\x01\xd8\x00\xdc", // osmanya 𐐀
"\x20\x00", // blank
"\x68\x00", // h
"\x65\x00", // e
"\x6c\x00", // l
"\x6c\x00", // l
"\x6f\x00", // o
"\x20\x00", // blank
"\x01\xd8\x01\xdc", // osmanya 𐐁
"\x0a\x00", // newline
];
$chars = implode('', $array);
$filename = $this->tempFile = File::temporaryFileName() . '.csv';
$fho = fopen($filename, 'wb');
self::assertNotFalse($fho);
fwrite($fho, $chars);
fclose($fho);
$reader = $this->newCsv();
$reader->setInputEncoding('UTF-16');
$reader->setDelimiter(',');
if (method_exists($reader, 'setChunkSize')) {
$reader->setChunkSize(2);
}
$spreadsheet = $reader->load($filename);
$sheet = $spreadsheet->getActiveSheet();
self::assertSame('𐐀 hello 𐐁', $sheet->getCell('A1')->getValue());
$spreadsheet->disconnectWorksheets();
}
public function testUnexpectedNoBom(): void
{
$reader = $this->newCsv();
$reader->setInputEncoding('UTF-16');
$reader->setDelimiter(',');
$array = [
"\xd8\x01\xdc\x00", // osmanya 𐐀
"\x00\x20", // blank
"\x00\x68", // h
"\x00\x65", // e
"\x00\x6c", // l
"\x00\x6c", // l
"\x00\x6f", // o
"\x00\x20", // blank
"\xd8\x01\xdc\x01", // osmanya 𐐁
"\x00\x0a", // newline
];
$chars = implode('', $array);
$filename = $this->tempFile = File::temporaryFileName() . '.csv';
$fho = fopen($filename, 'wb');
self::assertNotFalse($fho);
fwrite($fho, $chars);
fclose($fho);
$spreadsheet = $reader->load($filename);
$sheet = $spreadsheet->getActiveSheet();
self::assertSame('𐐀 hello 𐐁', $sheet->getCell('A1')->getValue());
$spreadsheet->disconnectWorksheets();
}
public function testNoChunkedUtf7(): void
{
$reader = $this->newCsv();
$reader->setInputEncoding('UTF-7');
$reader->setDelimiter(',');
$chars = 'Hello,World+ACE-';
$filename = $this->tempFile = File::temporaryFileName() . '.csv';
$fho = fopen($filename, 'wb');
self::assertNotFalse($fho);
fwrite($fho, $chars);
fclose($fho);
$spreadsheet = $reader->load($filename);
$sheet = $spreadsheet->getActiveSheet();
self::assertSame('Hello', $sheet->getCell('A1')->getValue());
self::assertSame('World!', $sheet->getCell('B1')->getValue());
$spreadsheet->disconnectWorksheets();
}
/**
* Test Windows-1252 encoded file with various special characters
* is correctly converted via streaming.
*/
public function testWindows1252SpecialCharacters(): void
{
// Build a CSV in Windows-1252 with various special characters
$utf8Csv = "Name,City,Note\n"
. "Müller,Zürich,Straße\n"
. "Café,Père,£100\n"
. "Smörgås,Göteborg,©2024\n";
$win1252Csv = mb_convert_encoding($utf8Csv, 'Windows-1252', 'UTF-8');
$filename = $this->tempFile = File::temporaryFileName();
file_put_contents($filename, $win1252Csv);
$reader = $this->newCsv();
$reader->setInputEncoding('CP1252');
$spreadsheet = $reader->load($filename);
$sheet = $spreadsheet->getActiveSheet();
// Row 1: headers
self::assertSame('Name', $sheet->getCell('A1')->getValue());
self::assertSame('City', $sheet->getCell('B1')->getValue());
self::assertSame('Note', $sheet->getCell('C1')->getValue());
// Row 2: German characters
self::assertSame('Müller', $sheet->getCell('A2')->getValue());
self::assertSame('Zürich', $sheet->getCell('B2')->getValue());
self::assertSame('Straße', $sheet->getCell('C2')->getValue());
// Row 3: French + symbol
self::assertSame('Café', $sheet->getCell('A3')->getValue());
self::assertSame('Père', $sheet->getCell('B3')->getValue());
self::assertSame('£100', $sheet->getCell('C3')->getValue());
// Row 4: Swedish + symbol
self::assertSame('Smörgås', $sheet->getCell('A4')->getValue());
self::assertSame('Göteborg', $sheet->getCell('B4')->getValue());
self::assertSame('©2024', $sheet->getCell('C4')->getValue());
$spreadsheet->disconnectWorksheets();
}
/**
* Test ISO-8859-1 encoded file with accented characters.
*/
public function testIso88591AccentedCharacters(): void
{
$utf8Csv = "première,deuxième,troisième\n"
. "quatrième,cinquième,sixième\n";
$iso88591Csv = mb_convert_encoding($utf8Csv, 'ISO-8859-1', 'UTF-8');
$filename = $this->tempFile = File::temporaryFileName();
file_put_contents($filename, $iso88591Csv);
$reader = $this->newCsv();
$reader->setInputEncoding('ISO-8859-1');
$spreadsheet = $reader->load($filename);
$sheet = $spreadsheet->getActiveSheet();
self::assertSame('première', $sheet->getCell('A1')->getValue());
self::assertSame('sixième', $sheet->getCell('C2')->getValue());
$spreadsheet->disconnectWorksheets();
}
/**
* Test that a large non-UTF-8 file produces identical results to
* what the old (non-streaming) approach would produce.
* This verifies that chunk boundaries don't corrupt data.
*/
public function testLargeFileProducesCorrectResults(): void
{
// Generate a file large enough to span multiple chunks (>64KB).
// Each row has accented characters to verify encoding conversion.
$utf8Rows = [];
$utf8Rows[] = 'id,name,description';
for ($i = 1; $i <= 2000; ++$i) {
$utf8Rows[] = sprintf(
'%d,"Ñoño %d","Descripción número %d con carácteres especiales: äöüß"',
$i,
$i,
$i
);
}
$utf8Csv = implode("\n", $utf8Rows) . "\n";
// Convert to ISO-8859-1
$isoCsv = mb_convert_encoding($utf8Csv, 'ISO-8859-1', 'UTF-8');
$filename = $this->tempFile = File::temporaryFileName();
file_put_contents($filename, $isoCsv);
// Verify file is large enough to trigger multiple chunks
self::assertGreaterThan(65536, strlen($isoCsv), 'Test file should be larger than one chunk');
$reader = $this->newCsv();
$reader->setInputEncoding('ISO-8859-1');
$spreadsheet = $reader->load($filename);
$sheet = $spreadsheet->getActiveSheet();
// Check first row (header)
self::assertSame('id', $sheet->getCell('A1')->getValue());
self::assertSame('name', $sheet->getCell('B1')->getValue());
self::assertSame('description', $sheet->getCell('C1')->getValue());
// Check several data rows including ones near chunk boundaries
self::assertSame('Ñoño 1', $sheet->getCell('B2')->getValue());
self::assertSame('Descripción número 1 con carácteres especiales: äöüß', $sheet->getCell('C2')->getValue());
// Check a row in the middle
self::assertSame('Ñoño 1000', $sheet->getCell('B1001')->getValue());
self::assertSame('Descripción número 1000 con carácteres especiales: äöüß', $sheet->getCell('C1001')->getValue());
// Check the last row
self::assertSame('Ñoño 2000', $sheet->getCell('B2001')->getValue());
self::assertSame('Descripción número 2000 con carácteres especiales: äöüß', $sheet->getCell('C2001')->getValue());
$spreadsheet->disconnectWorksheets();
}
/**
* Test a large UTF-16LE file to verify multi-byte character boundary
* handling across chunks.
*/
public function testLargeUtf16leFile(): void
{
$utf8Rows = [];
$utf8Rows[] = 'id,value';
for ($i = 1; $i <= 1500; ++$i) {
// Include characters that use multi-byte UTF-16 sequences
$utf8Rows[] = sprintf('%d,"Ströëm café résumé #%d"', $i, $i);
}
$utf8Csv = implode("\n", $utf8Rows) . "\n";
// Convert to UTF-16LE (2 bytes per character)
$utf16leCsv = mb_convert_encoding($utf8Csv, 'UTF-16LE', 'UTF-8');
$filename = $this->tempFile = File::temporaryFileName() . '.csv';
file_put_contents($filename, $utf16leCsv);
self::assertGreaterThan(65536, strlen($utf16leCsv), 'UTF-16LE file should be larger than one chunk');
$reader = $this->newCsv();
$reader->setInputEncoding('UTF-16LE');
$spreadsheet = $reader->load($filename);
$sheet = $spreadsheet->getActiveSheet();
self::assertSame('id', $sheet->getCell('A1')->getValue());
self::assertSame('value', $sheet->getCell('B1')->getValue());
self::assertSame('Ströëm café résumé #1', $sheet->getCell('B2')->getValue());
self::assertSame('Ströëm café résumé #750', $sheet->getCell('B751')->getValue());
self::assertSame('Ströëm café résumé #1500', $sheet->getCell('B1501')->getValue());
$spreadsheet->disconnectWorksheets();
}
/**
* Test a large UTF-32BE file to verify 4-byte character boundary
* handling across chunks.
*/
public function testLargeUtf32beFile(): void
{
$utf8Rows = [];
$utf8Rows[] = 'col1,col2';
for ($i = 1; $i <= 1000; ++$i) {
$utf8Rows[] = sprintf('%d,"Ünïcödé têst %d"', $i, $i);
}
$utf8Csv = implode("\n", $utf8Rows) . "\n";
$utf32beCsv = mb_convert_encoding($utf8Csv, 'UTF-32BE', 'UTF-8');
$filename = $this->tempFile = File::temporaryFileName() . '.csv';
file_put_contents($filename, $utf32beCsv);
self::assertGreaterThan(65536, strlen($utf32beCsv), 'UTF-32BE file should be larger than one chunk');
$reader = $this->newCsv();
$reader->setInputEncoding('UTF-32BE');
$spreadsheet = $reader->load($filename);
$sheet = $spreadsheet->getActiveSheet();
self::assertSame('col1', $sheet->getCell('A1')->getValue());
self::assertSame('col2', $sheet->getCell('B1')->getValue());
self::assertSame('Ünïcödé têst 1', $sheet->getCell('B2')->getValue());
self::assertSame('Ünïcödé têst 1000', $sheet->getCell('B1001')->getValue());
$spreadsheet->disconnectWorksheets();
}
/**
* Test that listWorksheetInfo also works with the streaming approach
* for non-UTF-8 files.
*/
public function testListWorksheetInfoWithNonUtf8(): void
{
$utf8Csv = "a,b,c\n1,2,3\n4,5,6\n";
$isoCsv = mb_convert_encoding($utf8Csv, 'ISO-8859-1', 'UTF-8');
$filename = $this->tempFile = File::temporaryFileName();
file_put_contents($filename, $isoCsv);
$reader = $this->newCsv();
$reader->setInputEncoding('ISO-8859-1');
$info = $reader->listWorksheetInfo($filename);
self::assertCount(1, $info);
self::assertSame(3, $info[0]['totalRows']);
self::assertSame(3, $info[0]['totalColumns']);
self::assertSame('C', $info[0]['lastColumnLetter']);
}
/**
* Test that listWorksheetNames works with non-UTF-8 streaming path.
*/
public function testListWorksheetNamesWithNonUtf8(): void
{
$utf8Csv = "a,b,c\n1,2,3\n";
$isoCsv = mb_convert_encoding($utf8Csv, 'ISO-8859-1', 'UTF-8');
$filename = $this->tempFile = File::temporaryFileName();
file_put_contents($filename, $isoCsv);
$reader = $this->newCsv();
$reader->setInputEncoding('ISO-8859-1');
$names = $reader->listWorksheetNames($filename);
self::assertCount(1, $names);
self::assertSame('Worksheet', $names[0]);
}
/**
* Test that an empty non-UTF-8 file is handled gracefully.
*/
public function testEmptyNonUtf8File(): void
{
$filename = $this->tempFile = File::temporaryFileName();
file_put_contents($filename, '');
$reader = $this->newCsv();
$reader->setInputEncoding('ISO-8859-1');
$spreadsheet = $reader->load($filename);
$sheet = $spreadsheet->getActiveSheet();
self::assertNull($sheet->getCell('A1')->getValue());
$spreadsheet->disconnectWorksheets();
}
/**
* Test the leftover flush path: a UTF-16LE file whose total byte
* count is odd triggers the leftover flush at lines 373-377 in Csv.php.
*
* The leftover contains an incomplete multi-byte character, so iconv
* raises a warning (converted to an exception by the test error handler).
*
* Note that, if iconv is not available for some reason,
* we will try mb_convert_encoding, which converts the leftover
* to a question mark with no message of any kind.
*/
public function testLeftoverFlushWithUnalignedUtf16le(): void
{
$utf8Csv = "a,b\n1,2\n";
$utf16leCsv = mb_convert_encoding($utf8Csv, 'UTF-16LE', 'UTF-8');
// Append a single extra byte to make total length odd (not aligned to charWidth=2)
$utf16leCsv .= "\x00";
$filename = $this->tempFile = File::temporaryFileName() . '.csv';
file_put_contents($filename, $utf16leCsv);
self::assertSame(1, strlen($utf16leCsv) % 2, 'File byte count should be odd to trigger leftover path');
$this->expectException(Exception::class);
if ($this->whichCsv === 'CsvIconv2') {
$this->expectExceptionMessage('invalid multibyte sequence');
} else {
$this->expectExceptionMessage('Detected an incomplete multibyte character');
}
$reader = $this->newCsv();
$reader->setInputEncoding('UTF-16LE');
$reader->load($filename);
}
/**
* Test the leftover flush path with UTF-32BE: file byte count not
* divisible by 4 triggers leftover handling with incomplete characters.
*
* Note that, if iconv is not available for some reason,
* we will try mb_convert_encoding, which converts the leftover
* to a question mark with no message of any kind.
*/
public function testLeftoverFlushWithUnalignedUtf32be(): void
{
$utf8Csv = "x,y\n3,4\n";
$utf32beCsv = mb_convert_encoding($utf8Csv, 'UTF-32BE', 'UTF-8');
// Append 2 extra bytes so length % 4 != 0
$utf32beCsv .= "\x00\x00";
$filename = $this->tempFile = File::temporaryFileName() . '.csv';
file_put_contents($filename, $utf32beCsv);
self::assertNotSame(0, strlen($utf32beCsv) % 4, 'File byte count should not be aligned to 4 to trigger leftover path');
$this->expectException(Exception::class);
if ($this->whichCsv === 'CsvIconv2') {
$this->expectExceptionMessage('invalid multibyte sequence');
} else {
$this->expectExceptionMessage('Detected an incomplete multibyte character');
}
$reader = $this->newCsv();
$reader->setInputEncoding('UTF-32BE');
$reader->load($filename);
}
/**
* Test encodingCharWidth returns 2 for UCS-2 variants by loading
* a file with UCS-2BE input encoding.
*/
#[DataProvider('providerUcsEncodings')]
public function testUcsEncodingVariants(string $inputEncoding, string $mbEncoding, int $charWidth): void
{
$utf8Csv = "a,b\n1,2\n";
$encoded = mb_convert_encoding($utf8Csv, $mbEncoding, 'UTF-8');
$filename = $this->tempFile = File::temporaryFileName() . '.csv';
file_put_contents($filename, $encoded);
// Verify encoding alignment: byte count should be divisible by char width
self::assertSame(0, strlen($encoded) % $charWidth);
$reader = $this->newCsv();
$reader->setInputEncoding($inputEncoding);
$spreadsheet = $reader->load($filename);
$sheet = $spreadsheet->getActiveSheet();
self::assertSame('a', $sheet->getCell('A1')->getValue());
self::assertSame('b', $sheet->getCell('B1')->getValue());
$spreadsheet->disconnectWorksheets();
}
public static function providerUcsEncodings(): array
{
return [
'UCS-2BE' => ['UCS-2BE', 'UCS-2BE', 2],
'UCS-2LE' => ['UCS-2LE', 'UCS-2LE', 2],
'UCS-4BE' => ['UCS-4BE', 'UCS-4BE', 4],
'UCS-4LE' => ['UCS-4LE', 'UCS-4LE', 4],
];
}
/**
* Test loadSpreadsheetFromString with non-UTF-8 content pre-converted
* to UTF-8. This verifies the string loading path works for content
* that was originally in a different encoding.
*/
public function testLoadFromStringWithConvertedEncoding(): void
{
$utf8Csv = "Name,City\nMüller,Zürich\nCafé,Père\n";
$reader = $this->newCsv();
$spreadsheet = $reader->loadSpreadsheetFromString($utf8Csv);
$sheet = $spreadsheet->getActiveSheet();
self::assertSame('Müller', $sheet->getCell('A2')->getValue());
self::assertSame('Zürich', $sheet->getCell('B2')->getValue());
self::assertSame('Café', $sheet->getCell('A3')->getValue());
$spreadsheet->disconnectWorksheets();
}
/**
* Test guess encoding still works with the streaming path.
*/
#[DataProvider('providerGuessEncodingStreaming')]
public function testGuessEncodingWithStreaming(string $filename): void
{
$reader = $this->newCsv();
$reader->setInputEncoding(Csv::GUESS_ENCODING);
$spreadsheet = $reader->load($filename);
$sheet = $spreadsheet->getActiveSheet();
self::assertEquals('première', $sheet->getCell('A1')->getValue());
self::assertEquals('sixième', $sheet->getCell('C2')->getValue());
$spreadsheet->disconnectWorksheets();
}
public static function providerGuessEncodingStreaming(): array
{
return [
'UTF-16BE' => ['tests/data/Reader/CSV/premiere.utf16be.csv'],
'UTF-16LE' => ['tests/data/Reader/CSV/premiere.utf16le.csv'],
'UTF-32BE' => ['tests/data/Reader/CSV/premiere.utf32be.csv'],
'UTF-32LE' => ['tests/data/Reader/CSV/premiere.utf32le.csv'],
'Win-1252' => ['tests/data/Reader/CSV/premiere.win1252.csv'],
];
}
}