Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,36 @@ The currently supported encoding extensions are as follows:
- `iconv`
- `mbstring`

#### Excel-friendly UTF-8 export

Microsoft Excel on Windows does not recognise a UTF-8 CSV unless it has a
byte-order mark, CRLF line endings, and an explicit UTF-8 declaration. Setting
all three options individually each time is repetitive and easy to get wrong.

The `excel` shorthand sets the right defaults in one go:

```php
$this->viewBuilder()
->setClassName('CsvView.Csv')
->setOptions([
'serialize' => 'data',
'excel' => true,
]);
```

`excel => true` is equivalent to:

```php
'bom' => true,
'eol' => "\r\n",
'csvEncoding' => 'UTF-8',
```

The shorthand always wins for the three keys it controls; if you need a
different combination (e.g. UTF-16, no BOM) do not enable `excel` and set the
individual keys yourself instead. Other CSV options (`delimiter`, `enclosure`,
`setSeparator`, `header`, `extract`, etc.) are independent and behave normally.

#### Setting the downloaded file name

By default, the downloaded file will be named after the last segment of the URL
Expand Down
29 changes: 29 additions & 0 deletions src/View/CsvView.php
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ class CsvView extends SerializedView
* - 'csvEncoding': (default 'UTF-8') CSV file encoding
* - 'dataEncoding': (default 'UTF-8') Encoding of data to be serialized
* - 'transcodingExtension': (default 'iconv') PHP extension to use for character encoding conversion
* - 'excel': (default false) Shorthand for an Excel-friendly UTF-8 export.
* When true, sets `bom => true`, `eol => "\r\n"`, and `csvEncoding => 'UTF-8'`.
* These specific keys are forced; if you need a different combination
* do not enable `excel` and set them individually instead.
*
* @var array<string, mixed>
*/
Expand All @@ -163,6 +167,7 @@ class CsvView extends SerializedView
'csvEncoding' => 'UTF-8',
'dataEncoding' => 'UTF-8',
'transcodingExtension' => self::EXTENSION_ICONV,
'excel' => false,
];

/**
Expand Down Expand Up @@ -210,6 +215,7 @@ public static function contentType(): string
protected function _serialize(array|string $serialize): string
{
$this->resetState();
$this->_applyExcelPreset();

$this->_renderRow($this->getConfig('header'));
$this->_renderContent();
Expand Down Expand Up @@ -246,6 +252,29 @@ public function __destruct()
}
}

/**
* Apply the `excel` shorthand if enabled: BOM + CRLF EOL + UTF-8 encoding,
* the three options Excel needs to open a UTF-8 CSV correctly on Windows.
*
* Applied at serialize-time (rather than `initialize()`) so the preset
* takes effect regardless of when `excel` is set — including the test
* pattern of constructing the view and then calling `setConfig()`.
*
* @return void
*/
protected function _applyExcelPreset(): void
{
if (!$this->getConfig('excel')) {
return;
}

$this->setConfig([
'bom' => true,
'eol' => "\r\n",
'csvEncoding' => 'UTF-8',
]);
}

/**
* Renders the body of the data to the csv
*
Expand Down
44 changes: 44 additions & 0 deletions tests/TestCase/View/CsvViewTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -597,4 +597,48 @@ public function testRenderViaExtractArrayValueThrows()
);
}
}

/**
* `excel => true` is a shorthand that forces the three options Excel
* needs to open a UTF-8 CSV correctly on Windows: BOM, CRLF line
* endings, and UTF-8 encoding.
*
* @return void
*/
public function testExcelPresetEmitsBomCrlfAndUtf8()
{
$data = [['Möhre', 'café'], ['ü', 'ß']];
$this->view->set(['data' => $data])
->setConfig(['serialize' => 'data', 'excel' => true]);

$bom = chr(0xEF) . chr(0xBB) . chr(0xBF);
$expected = $bom . 'Möhre,café' . "\r\n" . 'ü,ß' . "\r\n";

$this->assertSame($expected, $this->view->render());
}

/**
* The Excel preset wins for the three keys it controls even when the
* user has explicitly set them to other values. `excel => true` is a
* single switch; for a different combination set the individual keys
* yourself instead of enabling the preset.
*
* @return void
*/
public function testExcelPresetOverridesIndividualKeys()
{
$data = [['a', 'b']];
$this->view->set(['data' => $data])
->setConfig([
'serialize' => 'data',
'excel' => true,
'bom' => false,
'eol' => "\n",
]);

$output = $this->view->render();
$bom = chr(0xEF) . chr(0xBB) . chr(0xBF);
$this->assertStringStartsWith($bom, $output);
$this->assertStringEndsWith("\r\n", $output);
}
}
Loading