From 27012b803619a74d31a06474192f17d40f167162 Mon Sep 17 00:00:00 2001 From: Lukas Heller <36259611+lpheller@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:04:51 +0200 Subject: [PATCH 1/6] Add CSV writing with write and append --- README.md | 59 +++++++++++++- src/Csv.php | 10 +++ src/CsvWriter.php | 152 ++++++++++++++++++++++++++++++++++++ tests/Unit/CsvWriteTest.php | 148 +++++++++++++++++++++++++++++++++++ 4 files changed, 367 insertions(+), 2 deletions(-) create mode 100644 src/CsvWriter.php create mode 100644 tests/Unit/CsvWriteTest.php diff --git a/README.md b/README.md index c2adc3c..84eb93b 100644 --- a/README.md +++ b/README.md @@ -170,13 +170,68 @@ Csv::read('data.csv') }); ``` +## Writing + +```php +Csv::make($rows)->toFile('out.csv')->write(); +``` + +`write()` replaces the file. Pass header names to get them as the first row: + +```php +Csv::make([['Ada', 'Berlin']]) + ->withHeaders(['name', 'city']) + ->toFile('out.csv') + ->write(); + +// name,city +// Ada,Berlin +``` + +Associative rows are put into header order regardless of the order of their +keys, and a column a row does not carry is written empty: + +```php +Csv::make([['city' => 'Berlin', 'name' => 'Ada'], ['name' => 'Bob']]) + ->withHeaders(['name', 'city']) + ->toFile('out.csv') + ->write(); + +// name,city +// Ada,Berlin +// Bob, +``` + +Objects are written by their public properties, so anything read with +`mapToObject()` can be written straight back out. + +### Appending + +`append()` keeps the existing contents and does not repeat the header row. When +no headers are set, the header already in the file defines the column order: + +```php +Csv::make([['city' => 'Hamburg', 'name' => 'Bob']]) + ->toFile('out.csv') + ->append(); +``` + +If the file is missing or empty, `append()` writes it like `write()` would, +header included. + +### Delimiter + +```php +Csv::make($rows)->delimiter(';')->toFile('out.csv')->write(); +``` + ## Known limitations - Backslash still acts as an escape character inside quoted fields, matching PHP's current `fgetcsv` default. A field ending in `\` can swallow its closing quote. -- Reading only. There is no CSV writer. -- No encoding conversion. Input is expected to be UTF-8. +- No encoding conversion. Input is expected to be UTF-8, and no BOM is written. +- Writing replaces or appends. There is no insert or update of existing rows. ## Development diff --git a/src/Csv.php b/src/Csv.php index 2887449..5d146d6 100644 --- a/src/Csv.php +++ b/src/Csv.php @@ -26,6 +26,16 @@ public static function read(string $filePath) return new self($filePath); } + /** + * Start writing rows to a CSV file. + * + * @param array $data Rows as arrays or objects + */ + public static function make(array $data): CsvWriter + { + return new CsvWriter($data); + } + /** * Set the delimiter for the CSV file. * diff --git a/src/CsvWriter.php b/src/CsvWriter.php new file mode 100644 index 0000000..1913c61 --- /dev/null +++ b/src/CsvWriter.php @@ -0,0 +1,152 @@ +filePath = $filePath; + + return $this; + } + + /** + * Write these names as the first row, and use them as the column order for + * associative rows. + */ + public function withHeaders(array $headers): static + { + $this->headers = $headers; + + return $this; + } + + public function delimiter(string $delimiter): static + { + $this->delimiter = $delimiter; + + return $this; + } + + /** + * Write the data, replacing whatever is in the file. + */ + public function write(): static + { + $handle = $this->openTarget('w'); + + $this->putRows($handle, $this->headers, $this->headers); + + fclose($handle); + + return $this; + } + + /** + * Append the data, keeping the existing contents and header row. Falls back + * to a full write when the file is missing or empty. + */ + public function append(): static + { + $existingHeaders = $this->headerRowInFile(); + + if ($existingHeaders === null) { + return $this->write(); + } + + $handle = $this->openTarget('a'); + + $this->putRows($handle, [], $this->headers ?: $existingHeaders); + + fclose($handle); + + return $this; + } + + /** + * @param resource $handle + * @param array $headerRow Written as the first row. Empty writes no header. + * @param array $columnOrder Column order for associative rows. + */ + protected function putRows($handle, array $headerRow, array $columnOrder): void + { + if ($headerRow !== []) { + fputcsv($handle, $headerRow, $this->delimiter, escape: '\\'); + } + + foreach ($this->data as $row) { + fputcsv($handle, $this->alignRow((array) $row, $columnOrder), $this->delimiter, escape: '\\'); + } + } + + /** + * Put an associative row into column order. A column the row does not carry + * is written empty — never as a placeholder that reads like real data. + */ + protected function alignRow(array $row, array $columnOrder): array + { + if ($columnOrder === [] || array_is_list($row)) { + return $row; + } + + return array_map(fn ($column) => $row[$column] ?? '', $columnOrder); + } + + /** + * The header already present in the target file, or null when there is + * nothing to append to. + */ + protected function headerRowInFile(): ?array + { + $this->assertTargetIsSet(); + + if (! is_file($this->filePath) || filesize($this->filePath) === 0) { + return null; + } + + $handle = fopen($this->filePath, 'r'); + $header = fgetcsv($handle, null, $this->delimiter, escape: '\\'); + fclose($handle); + + return $header === false ? null : $header; + } + + /** + * @return resource + */ + protected function openTarget(string $mode) + { + $this->assertTargetIsSet(); + + $directory = dirname($this->filePath); + + if (! is_dir($directory) || ! is_writable($directory)) { + throw new \RuntimeException("Cannot write to directory: {$directory}"); + } + + if (is_file($this->filePath) && ! is_writable($this->filePath)) { + throw new \RuntimeException("Cannot write to file: {$this->filePath}"); + } + + return fopen($this->filePath, $mode); + } + + protected function assertTargetIsSet(): void + { + if ($this->filePath === null) { + throw new \RuntimeException('No target file set, call toFile() before writing.'); + } + } +} diff --git a/tests/Unit/CsvWriteTest.php b/tests/Unit/CsvWriteTest.php new file mode 100644 index 0000000..ffbebf3 --- /dev/null +++ b/tests/Unit/CsvWriteTest.php @@ -0,0 +1,148 @@ +file = tempnam(sys_get_temp_dir(), 'simple-csv-write').'.csv'; +}); + +afterEach(function () { + if (is_file($this->file)) { + unlink($this->file); + } +}); + +test('It writes plain rows', function () { + + Csv::make([['Foo', 'Bar'], ['Foo1', 'Bar1']]) + ->toFile($this->file) + ->write(); + + expect(file_get_contents($this->file))->toBe("Foo,Bar\nFoo1,Bar1\n"); +}); + +test('It writes the headers as the first row', function () { + + Csv::make([['Foo', 'Bar']]) + ->withHeaders(['Col1', 'Col2']) + ->toFile($this->file) + ->write(); + + expect(file_get_contents($this->file))->toBe("Col1,Col2\nFoo,Bar\n"); +}); + +test('It puts associative rows into header order', function () { + + Csv::make([['Col2' => 'Bar', 'Col1' => 'Foo']]) + ->withHeaders(['Col1', 'Col2']) + ->toFile($this->file) + ->write(); + + expect(file_get_contents($this->file))->toBe("Col1,Col2\nFoo,Bar\n"); +}); + +test('It writes a missing column as empty, not as a placeholder', function () { + + Csv::make([['Col1' => 'A', 'Col3' => 'C']]) + ->withHeaders(['Col1', 'Col2', 'Col3']) + ->toFile($this->file) + ->write(); + + expect(file_get_contents($this->file))->toBe("Col1,Col2,Col3\nA,,C\n"); +}); + +test('It writes objects by their public properties', function () { + + $row = new stdClass; + $row->Col1 = 'Foo'; + $row->Col2 = 'Bar'; + + Csv::make([$row]) + ->withHeaders(['Col1', 'Col2']) + ->toFile($this->file) + ->write(); + + expect(file_get_contents($this->file))->toBe("Col1,Col2\nFoo,Bar\n"); +}); + +test('It replaces the file contents on write', function () { + + file_put_contents($this->file, "old,data\nthat,goes\n"); + + Csv::make([['Foo', 'Bar']])->toFile($this->file)->write(); + + expect(file_get_contents($this->file))->toBe("Foo,Bar\n"); +}); + +test('It appends without repeating the header row', function () { + + Csv::make([['Foo', 'Bar']]) + ->withHeaders(['Col1', 'Col2']) + ->toFile($this->file) + ->write(); + + Csv::make([['Foo1', 'Bar1']]) + ->toFile($this->file) + ->append(); + + expect(file_get_contents($this->file))->toBe("Col1,Col2\nFoo,Bar\nFoo1,Bar1\n"); +}); + +test('It appends associative rows in the order of the header already in the file', function () { + + file_put_contents($this->file, "Col1,Col2,Col3\n"); + + Csv::make([['Col3' => 'C', 'Col1' => 'A']]) + ->toFile($this->file) + ->append(); + + expect(file_get_contents($this->file))->toBe("Col1,Col2,Col3\nA,,C\n"); +}); + +test('It writes the header when appending to an empty file', function () { + + Csv::make([['Foo', 'Bar']]) + ->withHeaders(['Col1', 'Col2']) + ->toFile($this->file) + ->append(); + + expect(file_get_contents($this->file))->toBe("Col1,Col2\nFoo,Bar\n"); +}); + +test('It writes with a custom delimiter', function () { + + Csv::make([['Foo', 'Bar']]) + ->withHeaders(['Col1', 'Col2']) + ->delimiter(';') + ->toFile($this->file) + ->write(); + + expect(file_get_contents($this->file))->toBe("Col1;Col2\nFoo;Bar\n"); +}); + +test('It throws when no target file was set', function () { + + expect(fn () => Csv::make([['Foo']])->write()) + ->toThrow(RuntimeException::class, 'No target file set, call toFile() before writing.'); +}); + +test('It throws when the target directory does not exist', function () { + + expect(fn () => Csv::make([['Foo']])->toFile('/no/such/dir/out.csv')->write()) + ->toThrow(RuntimeException::class, 'Cannot write to directory: /no/such/dir'); +}); + +test('What it writes can be read back', function () { + + $rows = [ + ['name' => 'Ada', 'city' => 'Berlin'], + ['name' => 'Bob', 'city' => 'Hamburg'], + ]; + + Csv::make($rows) + ->withHeaders(['name', 'city']) + ->toFile($this->file) + ->write(); + + expect(Csv::read($this->file)->mapToHeaders()->toArray())->toBe($rows); +}); From d802aa2497724b7fc1f9d69706647c4b6beb2f41 Mon Sep 17 00:00:00 2001 From: Lukas Heller <36259611+lpheller@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:21:26 +0200 Subject: [PATCH 2/6] Add crlf() for Excel-compatible line endings --- README.md | 8 +++++++- src/CsvWriter.php | 16 ++++++++++++++-- tests/Unit/CsvWriteTest.php | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 84eb93b..a6d0de1 100644 --- a/README.md +++ b/README.md @@ -219,12 +219,18 @@ Csv::make([['city' => 'Hamburg', 'name' => 'Bob']]) If the file is missing or empty, `append()` writes it like `write()` would, header included. -### Delimiter +### Delimiter and line endings ```php Csv::make($rows)->delimiter(';')->toFile('out.csv')->write(); ``` +Rows end with `\n`. Excel on Windows expects `\r\n`: + +```php +Csv::make($rows)->crlf()->toFile('out.csv')->write(); +``` + ## Known limitations - Backslash still acts as an escape character inside quoted fields, matching diff --git a/src/CsvWriter.php b/src/CsvWriter.php index 1913c61..3774382 100644 --- a/src/CsvWriter.php +++ b/src/CsvWriter.php @@ -10,6 +10,8 @@ class CsvWriter protected string $delimiter = ','; + protected string $lineEnding = "\n"; + public function __construct(protected array $data) {} /** @@ -40,6 +42,16 @@ public function delimiter(string $delimiter): static return $this; } + /** + * End rows with CRLF instead of LF, which is what Excel on Windows expects. + */ + public function crlf(): static + { + $this->lineEnding = "\r\n"; + + return $this; + } + /** * Write the data, replacing whatever is in the file. */ @@ -83,11 +95,11 @@ public function append(): static protected function putRows($handle, array $headerRow, array $columnOrder): void { if ($headerRow !== []) { - fputcsv($handle, $headerRow, $this->delimiter, escape: '\\'); + fputcsv($handle, $headerRow, $this->delimiter, escape: '\\', eol: $this->lineEnding); } foreach ($this->data as $row) { - fputcsv($handle, $this->alignRow((array) $row, $columnOrder), $this->delimiter, escape: '\\'); + fputcsv($handle, $this->alignRow((array) $row, $columnOrder), $this->delimiter, escape: '\\', eol: $this->lineEnding); } } diff --git a/tests/Unit/CsvWriteTest.php b/tests/Unit/CsvWriteTest.php index ffbebf3..ccce24b 100644 --- a/tests/Unit/CsvWriteTest.php +++ b/tests/Unit/CsvWriteTest.php @@ -120,6 +120,42 @@ expect(file_get_contents($this->file))->toBe("Col1;Col2\nFoo;Bar\n"); }); +test('It writes CRLF line endings for Excel', function () { + + Csv::make([['Foo', 'Bar']]) + ->withHeaders(['Col1', 'Col2']) + ->crlf() + ->toFile($this->file) + ->write(); + + expect(file_get_contents($this->file))->toBe("Col1,Col2\r\nFoo,Bar\r\n"); +}); + +test('It keeps the line ending when appending', function () { + + Csv::make([['Foo', 'Bar']]) + ->withHeaders(['Col1', 'Col2']) + ->crlf() + ->toFile($this->file) + ->write(); + + Csv::make([['Foo1', 'Bar1']]) + ->crlf() + ->toFile($this->file) + ->append(); + + expect(file_get_contents($this->file))->toBe("Col1,Col2\r\nFoo,Bar\r\nFoo1,Bar1\r\n"); +}); + +test('It still reads back what it wrote with CRLF', function () { + + $rows = [['name' => 'Ada', 'city' => 'Berlin']]; + + Csv::make($rows)->withHeaders(['name', 'city'])->crlf()->toFile($this->file)->write(); + + expect(Csv::read($this->file)->mapToHeaders()->toArray())->toBe($rows); +}); + test('It throws when no target file was set', function () { expect(fn () => Csv::make([['Foo']])->write()) From 25d48624e61fce3e81c10ca1c3a190bd7db71abb Mon Sep 17 00:00:00 2001 From: Lukas Heller <36259611+lpheller@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:30:37 +0200 Subject: [PATCH 3/6] Add insertAt() for inserting rows at a record position --- README.md | 29 ++++++++++++- src/CsvWriter.php | 71 ++++++++++++++++++++++++++++++- tests/Unit/CsvWriteTest.php | 85 ++++++++++++++++++++++++++++++++++++- 3 files changed, 181 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a6d0de1..661b208 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,33 @@ Csv::make([['city' => 'Hamburg', 'name' => 'Bob']]) If the file is missing or empty, `append()` writes it like `write()` would, header included. +### Inserting at a position + +`insertAt()` puts rows in front of an existing record. Records are counted from +1 and the header is record 1, so the first data row is position 2: + +```php +// Col1,Col2 +// A,A +// B,B + +Csv::make([['NEW', 'NEW']])->toFile('data.csv')->insertAt(3); + +// Col1,Col2 +// A,A +// NEW,NEW +// B,B +``` + +Everything after the insert is copied byte for byte, so quoting and spacing of +untouched records survive. The file is rebuilt next to itself and moved into +place in one step, which means a crash mid-write cannot leave a half-written +file behind. Memory stays constant regardless of file size — inserting into a +1M row file costs about 2 MB. + +A position past the end appends. A missing or empty file is written from +scratch, like `write()`. + ### Delimiter and line endings ```php @@ -237,7 +264,7 @@ Csv::make($rows)->crlf()->toFile('out.csv')->write(); PHP's current `fgetcsv` default. A field ending in `\` can swallow its closing quote. - No encoding conversion. Input is expected to be UTF-8, and no BOM is written. -- Writing replaces or appends. There is no insert or update of existing rows. +- Existing records can be inserted in front of, but not changed or removed. ## Development diff --git a/src/CsvWriter.php b/src/CsvWriter.php index 3774382..ad55b02 100644 --- a/src/CsvWriter.php +++ b/src/CsvWriter.php @@ -87,6 +87,68 @@ public function append(): static return $this; } + /** + * Insert the data before the record at $position, counting from 1. The + * header occupies record 1, so the first data row is position 2. Records + * after the insert are copied byte for byte and keep their formatting. + * + * A position past the end of the file appends. + */ + public function insertAt(int $position): static + { + if ($position < 1) { + throw new \RuntimeException('Position must be 1 or higher.'); + } + + $existingHeaders = $this->headerRowInFile(); + + if ($existingHeaders === null) { + return $this->write(); + } + + $this->assertTargetIsWritable(); + + $source = fopen($this->filePath, 'r'); + $offset = $this->offsetOfRecord($source, $position); + + $temporaryPath = tempnam(dirname($this->filePath), 'simple-csv'); + $target = fopen($temporaryPath, 'w'); + + rewind($source); + stream_copy_to_stream($source, $target, $offset); + $this->putRows($target, [], $this->headers ?: $existingHeaders); + stream_copy_to_stream($source, $target); + + fclose($source); + fclose($target); + + chmod($temporaryPath, fileperms($this->filePath) & 0777); + rename($temporaryPath, $this->filePath); + + return $this; + } + + /** + * Byte offset at which the record at $position starts. Records are counted + * with fgetcsv, so a newline inside a quoted field does not shift the count. + * + * @param resource $source + */ + protected function offsetOfRecord($source, int $position): int + { + $offset = 0; + + for ($record = 1; $record < $position; $record++) { + if (fgetcsv($source, null, $this->delimiter, escape: '\\') === false) { + break; + } + + $offset = ftell($source); + } + + return $offset; + } + /** * @param resource $handle * @param array $headerRow Written as the first row. Empty writes no header. @@ -139,6 +201,13 @@ protected function headerRowInFile(): ?array * @return resource */ protected function openTarget(string $mode) + { + $this->assertTargetIsWritable(); + + return fopen($this->filePath, $mode); + } + + protected function assertTargetIsWritable(): void { $this->assertTargetIsSet(); @@ -151,8 +220,6 @@ protected function openTarget(string $mode) if (is_file($this->filePath) && ! is_writable($this->filePath)) { throw new \RuntimeException("Cannot write to file: {$this->filePath}"); } - - return fopen($this->filePath, $mode); } protected function assertTargetIsSet(): void diff --git a/tests/Unit/CsvWriteTest.php b/tests/Unit/CsvWriteTest.php index ccce24b..2d2da11 100644 --- a/tests/Unit/CsvWriteTest.php +++ b/tests/Unit/CsvWriteTest.php @@ -3,7 +3,8 @@ use Heller\SimpleCsv\Csv; beforeEach(function () { - $this->file = tempnam(sys_get_temp_dir(), 'simple-csv-write').'.csv'; + // a unique path, deliberately not an existing file + $this->file = sys_get_temp_dir().'/simple-csv-'.uniqid().'.csv'; }); afterEach(function () { @@ -156,6 +157,88 @@ expect(Csv::read($this->file)->mapToHeaders()->toArray())->toBe($rows); }); +test('It inserts rows at a position', function () { + + file_put_contents($this->file, "Col1,Col2\nA,A\nB,B\n"); + + Csv::make([['NEW', 'NEW']])->toFile($this->file)->insertAt(3); + + expect(file_get_contents($this->file))->toBe("Col1,Col2\nA,A\nNEW,NEW\nB,B\n"); +}); + +test('It inserts several rows at once', function () { + + file_put_contents($this->file, "Col1,Col2\nA,A\n"); + + Csv::make([['X', 'X'], ['Y', 'Y']])->toFile($this->file)->insertAt(2); + + expect(file_get_contents($this->file))->toBe("Col1,Col2\nX,X\nY,Y\nA,A\n"); +}); + +test('It aligns inserted associative rows to the header in the file', function () { + + file_put_contents($this->file, "Col1,Col2,Col3\nA,A,A\n"); + + Csv::make([['Col3' => 'C', 'Col1' => 'A']])->toFile($this->file)->insertAt(2); + + expect(file_get_contents($this->file))->toBe("Col1,Col2,Col3\nA,,C\nA,A,A\n"); +}); + +test('It counts records, not lines, when a field contains a newline', function () { + + file_put_contents($this->file, "id,text\n1,\"line A\nline B\"\n2,ok\n"); + + // record 2 is the quoted multi-line one, so this lands in front of record 3 + Csv::make([['3', 'inserted']])->toFile($this->file)->insertAt(3); + + expect(file_get_contents($this->file)) + ->toBe("id,text\n1,\"line A\nline B\"\n3,inserted\n2,ok\n"); +}); + +test('It leaves the records after the insert untouched', function () { + + file_put_contents($this->file, "id,text\n1,\"keeps its spacing\"\n2,\"and, its, quotes\"\n"); + + Csv::make([['0', 'first']])->toFile($this->file)->insertAt(2); + + expect(file_get_contents($this->file)) + ->toBe("id,text\n0,first\n1,\"keeps its spacing\"\n2,\"and, its, quotes\"\n"); +}); + +test('It appends when the position is past the end of the file', function () { + + file_put_contents($this->file, "Col1,Col2\nA,A\n"); + + Csv::make([['Z', 'Z']])->toFile($this->file)->insertAt(99); + + expect(file_get_contents($this->file))->toBe("Col1,Col2\nA,A\nZ,Z\n"); +}); + +test('It writes the file when inserting into a missing one', function () { + + Csv::make([['A', 'B']])->withHeaders(['Col1', 'Col2'])->toFile($this->file)->insertAt(5); + + expect(file_get_contents($this->file))->toBe("Col1,Col2\nA,B\n"); +}); + +test('It throws on a position below 1', function () { + + file_put_contents($this->file, "Col1,Col2\nA,A\n"); + + expect(fn () => Csv::make([['X', 'X']])->toFile($this->file)->insertAt(0)) + ->toThrow(RuntimeException::class, 'Position must be 1 or higher.'); +}); + +test('It keeps the file permissions when inserting', function () { + + file_put_contents($this->file, "Col1,Col2\nA,A\n"); + chmod($this->file, 0640); + + Csv::make([['X', 'X']])->toFile($this->file)->insertAt(2); + + expect(fileperms($this->file) & 0777)->toBe(0640); +}); + test('It throws when no target file was set', function () { expect(fn () => Csv::make([['Foo']])->write()) From e2a613545281c32948687eb446867e55e24750d9 Mon Sep 17 00:00:00 2001 From: Lukas Heller <36259611+lpheller@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:32:30 +0200 Subject: [PATCH 4/6] Skip the permission test on Windows --- tests/Unit/CsvWriteTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/CsvWriteTest.php b/tests/Unit/CsvWriteTest.php index 2d2da11..90a89db 100644 --- a/tests/Unit/CsvWriteTest.php +++ b/tests/Unit/CsvWriteTest.php @@ -237,7 +237,7 @@ Csv::make([['X', 'X']])->toFile($this->file)->insertAt(2); expect(fileperms($this->file) & 0777)->toBe(0640); -}); +})->skipOnWindows('Windows has no POSIX permission bits.'); test('It throws when no target file was set', function () { From e7d8e8ad864414ba95c143f0cd0c17ab7b707ea1 Mon Sep 17 00:00:00 2001 From: Lukas Heller <36259611+lpheller@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:46:15 +0200 Subject: [PATCH 5/6] Add encoding() for reading non UTF-8 files and bom() for Excel exports --- README.md | 23 ++++++++++++++++++++--- src/Csv.php | 13 +++++++++++++ src/CsvWriter.php | 19 +++++++++++++++++++ src/Support/FileHandler.php | 22 +++++++++++++++++++++- tests/Unit/CsvTest.php | 15 +++++++++++++++ tests/Unit/CsvWriteTest.php | 20 ++++++++++++++++++++ 6 files changed, 108 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 661b208..ce0f8ad 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,19 @@ result, so a typo in a filename cannot look like an empty import. Csv::read('data.csv')->delimiter(';')->toArray(); ``` +### Encoding + +Files that are not UTF-8 are converted while reading. Anything `iconv` knows +works as a name, `Windows-1252` covers most Excel exports: + +```php +Csv::read('export.csv')->encoding('Windows-1252')->toArray(); +``` + +The conversion runs as a stream filter, so it costs nothing per row. Without +it, non-ASCII characters come back as invalid UTF-8 and anything downstream +that expects valid UTF-8 — `toJson()`, a database write — fails on them. + ## Header mapping `mapToHeaders()` uses a row of the CSV as the keys for every data row. The @@ -252,18 +265,22 @@ scratch, like `write()`. Csv::make($rows)->delimiter(';')->toFile('out.csv')->write(); ``` -Rows end with `\n`. Excel on Windows expects `\r\n`: +Rows end with `\n`. Excel on Windows expects `\r\n`, and needs a UTF-8 BOM to +read anything outside ASCII correctly: ```php -Csv::make($rows)->crlf()->toFile('out.csv')->write(); +Csv::make($rows)->delimiter(';')->crlf()->bom()->toFile('export.csv')->write(); ``` +`bom()` only applies to `write()`. `append()` and `insertAt()` leave a file +that already has content alone. + ## Known limitations - Backslash still acts as an escape character inside quoted fields, matching PHP's current `fgetcsv` default. A field ending in `\` can swallow its closing quote. -- No encoding conversion. Input is expected to be UTF-8, and no BOM is written. +- Output is always UTF-8. Only reading converts between encodings. - Existing records can be inserted in front of, but not changed or removed. ## Development diff --git a/src/Csv.php b/src/Csv.php index 5d146d6..fee30a8 100644 --- a/src/Csv.php +++ b/src/Csv.php @@ -49,6 +49,19 @@ public function delimiter(string $delimiter) return $this; } + /** + * Convert the file from this encoding to UTF-8 while reading. + * + * @param string $encoding Any iconv name, for example 'Windows-1252' + * @return $this + */ + public function encoding(string $encoding) + { + $this->processor->fileHandler->encoding($encoding); + + return $this; + } + /** * Map the CSV data to header keys. * The header row will be used as the keys for the data rows. diff --git a/src/CsvWriter.php b/src/CsvWriter.php index ad55b02..e00710b 100644 --- a/src/CsvWriter.php +++ b/src/CsvWriter.php @@ -12,6 +12,8 @@ class CsvWriter protected string $lineEnding = "\n"; + protected bool $bom = false; + public function __construct(protected array $data) {} /** @@ -52,6 +54,19 @@ public function crlf(): static return $this; } + /** + * Start the file with a UTF-8 BOM. Without it Excel reads the file as the + * local ANSI codepage and mangles anything outside ASCII. + * + * Ignored by append() and insertAt() on a file that already has content. + */ + public function bom(): static + { + $this->bom = true; + + return $this; + } + /** * Write the data, replacing whatever is in the file. */ @@ -59,6 +74,10 @@ public function write(): static { $handle = $this->openTarget('w'); + if ($this->bom) { + fwrite($handle, "\xEF\xBB\xBF"); + } + $this->putRows($handle, $this->headers, $this->headers); fclose($handle); diff --git a/src/Support/FileHandler.php b/src/Support/FileHandler.php index dcde098..fe2daba 100644 --- a/src/Support/FileHandler.php +++ b/src/Support/FileHandler.php @@ -6,8 +6,21 @@ class FileHandler { protected $cachedContent; + protected ?string $encoding = null; + public function __construct(protected string $filePath) {} + /** + * Convert the file from this encoding to UTF-8 while reading, for example + * 'Windows-1252' for a German Excel export. + */ + public function encoding(?string $encoding): static + { + $this->encoding = $encoding; + + return $this; + } + /** * Open the file and return a stream resource * @@ -17,7 +30,14 @@ public function openFile() { $handle = str_starts_with($this->filePath, 'http') ? $this->handleFromUrl() : $this->handleFromPath(); - return $this->skipBom($handle); + // BOM first, on the raw bytes, so the filter never sees a rewind + $handle = $this->skipBom($handle); + + if ($this->encoding !== null) { + stream_filter_append($handle, "convert.iconv.{$this->encoding}/UTF-8"); + } + + return $handle; } /** diff --git a/tests/Unit/CsvTest.php b/tests/Unit/CsvTest.php index cc13f6c..753bc55 100644 --- a/tests/Unit/CsvTest.php +++ b/tests/Unit/CsvTest.php @@ -215,6 +215,21 @@ public function normalizeHeaders($headers) ]); }); +test('It converts a non UTF-8 file while reading', function () { + + $file = sys_get_temp_dir().'/simple-csv-'.uniqid().'.csv'; + file_put_contents($file, mb_convert_encoding("name,stadt\nMüller,Köln\n", 'Windows-1252', 'UTF-8')); + + $csv = Csv::read($file)->encoding('Windows-1252')->mapToHeaders(); + + expect($csv->toArray())->toBe([['name' => 'Müller', 'stadt' => 'Köln']]); + + // the whole point: without the conversion this throws on malformed UTF-8 + expect($csv->toJson())->toBe('[{"name":"M\u00fcller","stadt":"K\u00f6ln"}]'); + + unlink($file); +}); + test('It returns the header row', function () { $file = __DIR__.'/../Fixtures/data.csv'; diff --git a/tests/Unit/CsvWriteTest.php b/tests/Unit/CsvWriteTest.php index 90a89db..d0b33e3 100644 --- a/tests/Unit/CsvWriteTest.php +++ b/tests/Unit/CsvWriteTest.php @@ -239,6 +239,26 @@ expect(fileperms($this->file) & 0777)->toBe(0640); })->skipOnWindows('Windows has no POSIX permission bits.'); +test('It writes a UTF-8 BOM for Excel', function () { + + Csv::make([['Müller', 'Köln']]) + ->withHeaders(['name', 'stadt']) + ->bom() + ->toFile($this->file) + ->write(); + + expect(bin2hex(substr(file_get_contents($this->file), 0, 3)))->toBe('efbbbf'); +}); + +test('A file written with a BOM reads back without it', function () { + + $rows = [['name' => 'Müller', 'stadt' => 'Köln']]; + + Csv::make($rows)->withHeaders(['name', 'stadt'])->bom()->crlf()->toFile($this->file)->write(); + + expect(Csv::read($this->file)->mapToHeaders()->toArray())->toBe($rows); +}); + test('It throws when no target file was set', function () { expect(fn () => Csv::make([['Foo']])->write()) From e6d79ca45dbf57f1031267d50ca2c8c5c2727952 Mon Sep 17 00:00:00 2001 From: Lukas Heller <36259611+lpheller@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:53:55 +0200 Subject: [PATCH 6/6] Parse RFC 4180 by default, add escape() for backslash escaped files --- README.md | 13 ++++++++++--- src/Csv.php | 14 ++++++++++++++ src/CsvProcessor.php | 13 ++++++++++--- src/CsvWriter.php | 21 +++++++++++++++++---- tests/Unit/CsvTest.php | 23 +++++++++++++++++++++++ tests/Unit/CsvWriteTest.php | 9 +++++++++ 6 files changed, 83 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ce0f8ad..3800124 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,16 @@ result, so a typo in a filename cannot look like an empty import. Csv::read('data.csv')->delimiter(';')->toArray(); ``` +### Escaping + +Fields are parsed the RFC 4180 way: a quote inside a quoted field is doubled, +and a backslash is just a character. Some producers, notably MySQL's +`SELECT ... INTO OUTFILE`, escape with a backslash instead: + +```php +Csv::read('dump.csv')->escape('\\')->toArray(); +``` + ### Encoding Files that are not UTF-8 are converted while reading. Anything `iconv` knows @@ -277,9 +287,6 @@ that already has content alone. ## Known limitations -- Backslash still acts as an escape character inside quoted fields, matching - PHP's current `fgetcsv` default. A field ending in `\` can swallow its - closing quote. - Output is always UTF-8. Only reading converts between encodings. - Existing records can be inserted in front of, but not changed or removed. diff --git a/src/Csv.php b/src/Csv.php index fee30a8..2cea7d0 100644 --- a/src/Csv.php +++ b/src/Csv.php @@ -49,6 +49,20 @@ public function delimiter(string $delimiter) return $this; } + /** + * Treat this character as an escape character inside quoted fields. Off by + * default, which is RFC 4180. Pass a backslash for files that use one, such + * as a MySQL SELECT ... INTO OUTFILE dump. + * + * @return $this + */ + public function escape(string $escape) + { + $this->processor->escape = $escape; + + return $this; + } + /** * Convert the file from this encoding to UTF-8 while reading. * diff --git a/src/CsvProcessor.php b/src/CsvProcessor.php index 53a1f6e..557ac13 100644 --- a/src/CsvProcessor.php +++ b/src/CsvProcessor.php @@ -23,6 +23,13 @@ class CsvProcessor public string $delimiter = ','; + /** + * RFC 4180 has no escape character, a quote is doubled instead. PHP's + * historic default is a backslash, which makes a field ending in one + * swallow its closing quote. + */ + public string $escape = ''; + public bool $skipEmptyRows = false; public array $headers = []; @@ -39,7 +46,7 @@ public function process() $rowNumber = 0; - while (($row = fgetcsv($handle, null, $this->delimiter, escape: '\\')) !== false) { + while (($row = fgetcsv($handle, null, $this->delimiter, escape: $this->escape)) !== false) { $rowNumber++; if (in_array($rowNumber, $this->skipRows)) { @@ -148,7 +155,7 @@ protected function getHeaderRowFromCsv() // Skip rows until the header row for ($i = 1; $i < $this->headerRow; $i++) { - if (fgetcsv($handle, escape: '\\') === false) { + if (fgetcsv($handle, escape: $this->escape) === false) { throw new \RuntimeException('Header row not found in CSV.'); } } @@ -157,7 +164,7 @@ protected function getHeaderRowFromCsv() $handle, null, $this->delimiter, - escape: '\\' + escape: $this->escape ); fclose($handle); diff --git a/src/CsvWriter.php b/src/CsvWriter.php index e00710b..e7fec32 100644 --- a/src/CsvWriter.php +++ b/src/CsvWriter.php @@ -10,6 +10,8 @@ class CsvWriter protected string $delimiter = ','; + protected string $escape = ''; + protected string $lineEnding = "\n"; protected bool $bom = false; @@ -44,6 +46,17 @@ public function delimiter(string $delimiter): static return $this; } + /** + * Treat this character as an escape character. Off by default, which is + * RFC 4180. + */ + public function escape(string $escape): static + { + $this->escape = $escape; + + return $this; + } + /** * End rows with CRLF instead of LF, which is what Excel on Windows expects. */ @@ -158,7 +171,7 @@ protected function offsetOfRecord($source, int $position): int $offset = 0; for ($record = 1; $record < $position; $record++) { - if (fgetcsv($source, null, $this->delimiter, escape: '\\') === false) { + if (fgetcsv($source, null, $this->delimiter, escape: $this->escape) === false) { break; } @@ -176,11 +189,11 @@ protected function offsetOfRecord($source, int $position): int protected function putRows($handle, array $headerRow, array $columnOrder): void { if ($headerRow !== []) { - fputcsv($handle, $headerRow, $this->delimiter, escape: '\\', eol: $this->lineEnding); + fputcsv($handle, $headerRow, $this->delimiter, escape: $this->escape, eol: $this->lineEnding); } foreach ($this->data as $row) { - fputcsv($handle, $this->alignRow((array) $row, $columnOrder), $this->delimiter, escape: '\\', eol: $this->lineEnding); + fputcsv($handle, $this->alignRow((array) $row, $columnOrder), $this->delimiter, escape: $this->escape, eol: $this->lineEnding); } } @@ -210,7 +223,7 @@ protected function headerRowInFile(): ?array } $handle = fopen($this->filePath, 'r'); - $header = fgetcsv($handle, null, $this->delimiter, escape: '\\'); + $header = fgetcsv($handle, null, $this->delimiter, escape: $this->escape); fclose($handle); return $header === false ? null : $header; diff --git a/tests/Unit/CsvTest.php b/tests/Unit/CsvTest.php index 753bc55..ddcbd33 100644 --- a/tests/Unit/CsvTest.php +++ b/tests/Unit/CsvTest.php @@ -230,6 +230,29 @@ public function normalizeHeaders($headers) unlink($file); }); +test('It reads a field that ends in a backslash', function () { + + $file = sys_get_temp_dir().'/simple-csv-'.uniqid().'.csv'; + file_put_contents($file, "pfad,name\n\"C:\\daten\\\",Ada\n"); + + expect(Csv::read($file)->mapToHeaders()->first()) + ->toBe(['pfad' => 'C:\daten\\', 'name' => 'Ada']); + + unlink($file); +}); + +test('It can still read backslash escaped files', function () { + + $file = sys_get_temp_dir().'/simple-csv-'.uniqid().'.csv'; + // a MySQL "SELECT ... INTO OUTFILE" style dump, where \" is an escaped quote + file_put_contents($file, "text,name\n\"say \\\"hi\\\"\",Ada\n"); + + expect(Csv::read($file)->escape('\\')->mapToHeaders()->first()) + ->toBe(['text' => 'say \"hi\"', 'name' => 'Ada']); + + unlink($file); +}); + test('It returns the header row', function () { $file = __DIR__.'/../Fixtures/data.csv'; diff --git a/tests/Unit/CsvWriteTest.php b/tests/Unit/CsvWriteTest.php index d0b33e3..b9fe298 100644 --- a/tests/Unit/CsvWriteTest.php +++ b/tests/Unit/CsvWriteTest.php @@ -259,6 +259,15 @@ expect(Csv::read($this->file)->mapToHeaders()->toArray())->toBe($rows); }); +test('A value containing backslashes and quotes survives a round trip', function () { + + $rows = [['pfad' => 'C:\daten\\', 'text' => 'say "hi"']]; + + Csv::make($rows)->withHeaders(['pfad', 'text'])->toFile($this->file)->write(); + + expect(Csv::read($this->file)->mapToHeaders()->toArray())->toBe($rows); +}); + test('It throws when no target file was set', function () { expect(fn () => Csv::make([['Foo']])->write())