Skip to content

Commit ce6ac1f

Browse files
authored
Fix For #1509 (#1518)
* Fix For #1509 User expected no CSV enclosures after $writer->setEnclosure(''), which had been changed to be consistent with $reader->setEnclosure(''). Writer will now omit enclosures after code above; no change to Reader. Tests have been added for this condition. * Add Option to Write CSV Enclosure Only When Required Allowing the user to specify no enclosure when writing a CSV can lead to a situation where PhpSpreadsheet (likewise Excel) will not read the resulting file as intended, e.g. if any cell contains a delimiter character. This is demonstrated in new test TestBadReread. No existing setting will rectify this situation. A better choice would be to add an option to write the enclosure only when it is needed, which is what Excel does. The RFC4180 spec at https://tools.ietf.org/html/rfc4180 states when it is needed - when the cell contains the delimiter, or the enclosure, or a newline. New test TestGoodReread demonstrates that the file is read as intended. The documentation has been updated to describe the new function, and to change the write example where the enclosure is set to null. * Scrutinizer Suggestions 3 minor changes, all in tests.
1 parent 82ea1d5 commit ce6ac1f

File tree

4 files changed

+265
-15
lines changed

4 files changed

+265
-15
lines changed

docs/topics/reading-and-writing-to-file.md

+16-2
Original file line numberDiff line numberDiff line change
@@ -478,7 +478,7 @@ imports onto the 6th sheet:
478478
```php
479479
$reader = new \PhpOffice\PhpSpreadsheet\Reader\Csv();
480480
$reader->setDelimiter(';');
481-
$reader->setEnclosure('');
481+
$reader->setEnclosure('"');
482482
$reader->setSheetIndex(5);
483483

484484
$reader->loadIntoExisting("05featuredemo.csv", $spreadsheet);
@@ -505,13 +505,26 @@ file:
505505
```php
506506
$writer = new \PhpOffice\PhpSpreadsheet\Writer\Csv($spreadsheet);
507507
$writer->setDelimiter(';');
508-
$writer->setEnclosure('');
508+
$writer->setEnclosure('"');
509509
$writer->setLineEnding("\r\n");
510510
$writer->setSheetIndex(0);
511511

512512
$writer->save("05featuredemo.csv");
513513
```
514514

515+
#### CSV enclosures
516+
517+
By default, all CSV fields are wrapped in the enclosure character,
518+
which defaults to double-quote.
519+
You can change to use the enclosure character only when required:
520+
521+
``` php
522+
$writer = new \PhpOffice\PhpSpreadsheet\Writer\Csv($spreadsheet);
523+
$writer->setEnclosureRequired(false);
524+
525+
$writer->save("05featuredemo.csv");
526+
```
527+
515528
#### Write a specific worksheet
516529

517530
CSV files can only contain one worksheet. Therefore, you can specify
@@ -538,6 +551,7 @@ $writer->save("05featuredemo.csv");
538551
CSV files are written in UTF-8. If they do not contain characters
539552
outside the ASCII range, nothing else need be done.
540553
However, if such characters are in the file,
554+
or if the file starts with the 2 characters 'ID',
541555
it should explicitly include a BOM file header;
542556
if it doesn't, Excel will not interpret those characters correctly.
543557
This can be enabled by using the following code:

src/PhpSpreadsheet/Writer/Csv.php

+30-12
Original file line numberDiff line numberDiff line change
@@ -168,9 +168,9 @@ public function getEnclosure()
168168
*
169169
* @return $this
170170
*/
171-
public function setEnclosure($pValue)
171+
public function setEnclosure($pValue = '"')
172172
{
173-
$this->enclosure = $pValue ? $pValue : '"';
173+
$this->enclosure = $pValue;
174174

175175
return $this;
176176
}
@@ -296,6 +296,20 @@ public function setSheetIndex($pValue)
296296
return $this;
297297
}
298298

299+
private $enclosureRequired = true;
300+
301+
public function setEnclosureRequired(bool $value): self
302+
{
303+
$this->enclosureRequired = $value;
304+
305+
return $this;
306+
}
307+
308+
public function getEnclosureRequired(): bool
309+
{
310+
return $this->enclosureRequired;
311+
}
312+
299313
/**
300314
* Write line to CSV file.
301315
*
@@ -305,24 +319,28 @@ public function setSheetIndex($pValue)
305319
private function writeLine($pFileHandle, array $pValues): void
306320
{
307321
// No leading delimiter
308-
$writeDelimiter = false;
322+
$delimiter = '';
309323

310324
// Build the line
311325
$line = '';
312326

313327
foreach ($pValues as $element) {
314-
// Escape enclosures
315-
$element = str_replace($this->enclosure, $this->enclosure . $this->enclosure, $element);
316-
317328
// Add delimiter
318-
if ($writeDelimiter) {
319-
$line .= $this->delimiter;
320-
} else {
321-
$writeDelimiter = true;
329+
$line .= $delimiter;
330+
$delimiter = $this->delimiter;
331+
// Escape enclosures
332+
$enclosure = $this->enclosure;
333+
if ($enclosure) {
334+
// If enclosure is not required, use enclosure only if
335+
// element contains newline, delimiter, or enclosure.
336+
if (!$this->enclosureRequired && strpbrk($element, "$delimiter$enclosure\n") === false) {
337+
$enclosure = '';
338+
} else {
339+
$element = str_replace($enclosure, $enclosure . $enclosure, $element);
340+
}
322341
}
323-
324342
// Add enclosed string
325-
$line .= $this->enclosure . $element . $this->enclosure;
343+
$line .= $enclosure . $element . $enclosure;
326344
}
327345

328346
// Add line ending
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
<?php
2+
3+
namespace PhpOffice\PhpSpreadsheetTests\Writer\Csv;
4+
5+
use PhpOffice\PhpSpreadsheet\Reader\Csv as CsvReader;
6+
use PhpOffice\PhpSpreadsheet\Shared\File;
7+
use PhpOffice\PhpSpreadsheet\Spreadsheet;
8+
use PhpOffice\PhpSpreadsheet\Writer\Csv as CsvWriter;
9+
use PhpOffice\PhpSpreadsheetTests\Functional;
10+
11+
class CsvEnclosureTest extends Functional\AbstractFunctional
12+
{
13+
private static $cellValues = [
14+
'A1' => '2020-06-03',
15+
'B1' => '000123',
16+
'C1' => '06.53',
17+
'D1' => '14.22',
18+
'A2' => '2020-06-04',
19+
'B2' => '000234',
20+
'C2' => '07.12',
21+
'D2' => '15.44',
22+
];
23+
24+
public function testNormalEnclosure(): void
25+
{
26+
$delimiter = ';';
27+
$enclosure = '"';
28+
$spreadsheet = new Spreadsheet();
29+
$sheet = $spreadsheet->getActiveSheet();
30+
foreach (self::$cellValues as $key => $value) {
31+
$sheet->setCellValue($key, $value);
32+
}
33+
$writer = new CsvWriter($spreadsheet);
34+
$writer->setDelimiter($delimiter);
35+
$writer->setEnclosure($enclosure);
36+
$filename = tempnam(File::sysGetTempDir(), 'phpspreadsheet-test');
37+
$writer->save($filename);
38+
$filedata = file_get_contents($filename);
39+
$filedata = preg_replace('/\\r?\\n/', $delimiter, $filedata);
40+
$reader = new CsvReader();
41+
$reader->setDelimiter($delimiter);
42+
$reader->setEnclosure($enclosure);
43+
$newspreadsheet = $reader->load($filename);
44+
unlink($filename);
45+
$sheet = $newspreadsheet->getActiveSheet();
46+
$expected = '';
47+
foreach (self::$cellValues as $key => $value) {
48+
self::assertEquals($value, $sheet->getCell($key)->getValue());
49+
$expected .= "$enclosure$value$enclosure$delimiter";
50+
}
51+
self::assertEquals($expected, $filedata);
52+
}
53+
54+
public function testNoEnclosure(): void
55+
{
56+
$delimiter = ';';
57+
$enclosure = '';
58+
$spreadsheet = new Spreadsheet();
59+
$sheet = $spreadsheet->getActiveSheet();
60+
foreach (self::$cellValues as $key => $value) {
61+
$sheet->setCellValue($key, $value);
62+
}
63+
$writer = new CsvWriter($spreadsheet);
64+
$writer->setDelimiter($delimiter);
65+
$writer->setEnclosure($enclosure);
66+
self::assertEquals('', $writer->getEnclosure());
67+
$filename = tempnam(File::sysGetTempDir(), 'phpspreadsheet-test');
68+
$writer->save($filename);
69+
$filedata = file_get_contents($filename);
70+
$filedata = preg_replace('/\\r?\\n/', $delimiter, $filedata);
71+
$reader = new CsvReader();
72+
$reader->setDelimiter($delimiter);
73+
$reader->setEnclosure($enclosure);
74+
self::assertEquals('"', $reader->getEnclosure());
75+
$newspreadsheet = $reader->load($filename);
76+
unlink($filename);
77+
$sheet = $newspreadsheet->getActiveSheet();
78+
$expected = '';
79+
foreach (self::$cellValues as $key => $value) {
80+
self::assertEquals($value, $sheet->getCell($key)->getValue());
81+
$expected .= "$enclosure$value$enclosure$delimiter";
82+
}
83+
self::assertEquals($expected, $filedata);
84+
}
85+
86+
public function testNotRequiredEnclosure1(): void
87+
{
88+
$delimiter = ';';
89+
$enclosure = '"';
90+
$spreadsheet = new Spreadsheet();
91+
$sheet = $spreadsheet->getActiveSheet();
92+
foreach (self::$cellValues as $key => $value) {
93+
$sheet->setCellValue($key, $value);
94+
}
95+
$writer = new CsvWriter($spreadsheet);
96+
self::assertTrue($writer->getEnclosureRequired());
97+
$writer->setEnclosureRequired(false)->setDelimiter($delimiter)->setEnclosure($enclosure);
98+
$filename = tempnam(File::sysGetTempDir(), 'phpspreadsheet-test');
99+
$writer->save($filename);
100+
$filedata = file_get_contents($filename);
101+
$filedata = preg_replace('/\\r?\\n/', $delimiter, $filedata);
102+
$reader = new CsvReader();
103+
$reader->setDelimiter($delimiter);
104+
$reader->setEnclosure($enclosure);
105+
$newspreadsheet = $reader->load($filename);
106+
unlink($filename);
107+
$sheet = $newspreadsheet->getActiveSheet();
108+
$expected = '';
109+
foreach (self::$cellValues as $key => $value) {
110+
self::assertEquals($value, $sheet->getCell($key)->getValue());
111+
$expected .= "$value$delimiter";
112+
}
113+
self::assertEquals($expected, $filedata);
114+
}
115+
116+
public function testNotRequiredEnclosure2(): void
117+
{
118+
$cellValues2 = [
119+
'A1' => '2020-06-03',
120+
'B1' => 'has,separator',
121+
'C1' => 'has;non-separator',
122+
'D1' => 'has"enclosure',
123+
'A2' => 'has space',
124+
'B2' => "has\nnewline",
125+
'C2' => '',
126+
'D2' => '15.44',
127+
'A3' => ' leadingspace',
128+
'B3' => 'trailingspace ',
129+
'C3' => '=D2*2',
130+
'D3' => ',leadingcomma',
131+
'A4' => 'trailingquote"',
132+
'B4' => 'unused',
133+
'C4' => 'unused',
134+
'D4' => 'unused',
135+
];
136+
$calcc3 = '30.88';
137+
$expected1 = '2020-06-03,"has,separator",has;non-separator,"has""enclosure"';
138+
$expected2 = 'has space,"has' . "\n" . 'newline",,15.44';
139+
$expected3 = ' leadingspace,trailingspace ,' . $calcc3 . ',",leadingcomma"';
140+
$expected4 = '"trailingquote""",unused,unused,unused';
141+
$expectedfile = "$expected1\n$expected2\n$expected3\n$expected4\n";
142+
$delimiter = ',';
143+
$enclosure = '"';
144+
$spreadsheet = new Spreadsheet();
145+
$sheet = $spreadsheet->getActiveSheet();
146+
foreach ($cellValues2 as $key => $value) {
147+
$sheet->setCellValue($key, $value);
148+
}
149+
$writer = new CsvWriter($spreadsheet);
150+
self::assertTrue($writer->getEnclosureRequired());
151+
$writer->setEnclosureRequired(false)->setDelimiter($delimiter)->setEnclosure($enclosure);
152+
$filename = tempnam(File::sysGetTempDir(), 'phpspreadsheet-test');
153+
$writer->save($filename);
154+
$filedata = file_get_contents($filename);
155+
$filedata = preg_replace('/\\r/', '', $filedata);
156+
$reader = new CsvReader();
157+
$reader->setDelimiter($delimiter);
158+
$reader->setEnclosure($enclosure);
159+
$newspreadsheet = $reader->load($filename);
160+
unlink($filename);
161+
$sheet = $newspreadsheet->getActiveSheet();
162+
foreach ($cellValues2 as $key => $value) {
163+
self::assertEquals(($key === 'C3') ? $calcc3 : $value, $sheet->getCell($key)->getValue());
164+
}
165+
self::assertEquals($expectedfile, $filedata);
166+
}
167+
168+
public function testGoodReread(): void
169+
{
170+
$delimiter = ',';
171+
$enclosure = '"';
172+
$spreadsheet = new Spreadsheet();
173+
$sheet = $spreadsheet->getActiveSheet();
174+
$sheet->setCellValue('A1', '1');
175+
$sheet->setCellValue('B1', '2,3');
176+
$sheet->setCellValue('C1', '4');
177+
$writer = new CsvWriter($spreadsheet);
178+
$writer->setEnclosureRequired(false)->setDelimiter($delimiter)->setEnclosure($enclosure);
179+
$filename = tempnam(File::sysGetTempDir(), 'phpspreadsheet-test');
180+
$writer->save($filename);
181+
$reader = new CsvReader();
182+
$reader->setDelimiter($delimiter);
183+
$reader->setEnclosure($enclosure);
184+
$newspreadsheet = $reader->load($filename);
185+
unlink($filename);
186+
$sheet = $newspreadsheet->getActiveSheet();
187+
self::assertEquals('1', $sheet->getCell('A1')->getValue());
188+
self::assertEquals('2,3', $sheet->getCell('B1')->getValue());
189+
self::assertEquals('4', $sheet->getCell('C1')->getValue());
190+
}
191+
192+
public function testBadReread(): void
193+
{
194+
$delimiter = ',';
195+
$enclosure = '';
196+
$spreadsheet = new Spreadsheet();
197+
$sheet = $spreadsheet->getActiveSheet();
198+
$sheet->setCellValue('A1', '1');
199+
$sheet->setCellValue('B1', '2,3');
200+
$sheet->setCellValue('C1', '4');
201+
$writer = new CsvWriter($spreadsheet);
202+
$writer->setDelimiter($delimiter)->setEnclosure($enclosure);
203+
$filename = tempnam(File::sysGetTempDir(), 'phpspreadsheet-test');
204+
$writer->save($filename);
205+
$reader = new CsvReader();
206+
$reader->setDelimiter($delimiter);
207+
$reader->setEnclosure($enclosure);
208+
$newspreadsheet = $reader->load($filename);
209+
unlink($filename);
210+
$sheet = $newspreadsheet->getActiveSheet();
211+
self::assertEquals('1', $sheet->getCell('A1')->getValue());
212+
self::assertEquals('2', $sheet->getCell('B1')->getValue());
213+
self::assertEquals('3', $sheet->getCell('C1')->getValue());
214+
self::assertEquals('4', $sheet->getCell('D1')->getValue());
215+
}
216+
}

tests/PhpSpreadsheetTests/Writer/Csv/CsvWriteTest.php

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?php
22

3-
namespace PhpOffice\PhpSpreadsheetTests\Functional;
3+
namespace PhpOffice\PhpSpreadsheetTests\Writer\Csv;
44

55
use PhpOffice\PhpSpreadsheet\Reader\Csv as CsvReader;
66
use PhpOffice\PhpSpreadsheet\Shared\File;
@@ -50,6 +50,8 @@ public function testDefaultSettings(): void
5050
$writer->setEnclosure('\'');
5151
self::assertEquals('\'', $writer->getEnclosure());
5252
$writer->setEnclosure('');
53+
self::assertEquals('', $writer->getEnclosure());
54+
$writer->setEnclosure();
5355
self::assertEquals('"', $writer->getEnclosure());
5456
self::assertEquals(PHP_EOL, $writer->getLineEnding());
5557
self::assertFalse($writer->getUseBOM());

0 commit comments

Comments
 (0)