diff --git a/src/Exporter/Excel/ExcelOpenSpoutExporter.php b/src/Exporter/Excel/ExcelOpenSpoutExporter.php
index 09c618db..db0a3ad3 100755
--- a/src/Exporter/Excel/ExcelOpenSpoutExporter.php
+++ b/src/Exporter/Excel/ExcelOpenSpoutExporter.php
@@ -13,6 +13,7 @@
namespace Omines\DataTablesBundle\Exporter\Excel;
use Omines\DataTablesBundle\Exporter\DataTableExporterInterface;
+use OpenSpout\Common\Entity\Cell;
use OpenSpout\Common\Entity\Row;
use OpenSpout\Common\Entity\Style\Style;
use OpenSpout\Writer\AutoFilter;
@@ -28,28 +29,61 @@ public function export(array $columnNames, \Iterator $data): \SplFileInfo
{
$filePath = sys_get_temp_dir() . '/' . uniqid('dt') . '.xlsx';
- // Header
- $rows = [Row::fromValues($columnNames, (new Style())->setFontBold())];
+ // Style definitions
+ $noWrapTextStyle = (new Style())->setShouldWrapText(false);
+ $boldStyle = (new Style())->setFontBold();
- // Data
- foreach ($data as $row) {
- // Remove HTML tags
- $values = array_map('strip_tags', $row);
- $rows[] = Row::fromValues($values);
- }
-
- // Write rows
$writer = new Writer();
$writer->openToFile($filePath);
- $writer->addRows($rows);
+
+ // Add header
+ $writer->addRow(Row::fromValues($columnNames, $boldStyle));
+
+ $truncated = false;
+ $maxCharactersPerCell = 32767; // E.g. https://support.microsoft.com/en-us/office/excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3
+ $rowCount = 0;
+
+ foreach ($data as $rowValues) {
+ $row = new Row([]);
+ foreach ($rowValues as $value) {
+ // I assume that $value is always a string
+
+ // The data that we get may contain rich HTML. But OpenSpout does not support this.
+ // We just strip all HTML tags and unescape the remaining text.
+ $value = htmlspecialchars_decode(strip_tags($value), ENT_QUOTES | ENT_SUBSTITUTE);
+
+ // Excel has a limit of 32,767 characters per cell
+ if (mb_strlen($value) > $maxCharactersPerCell) {
+ $truncated = true;
+ $value = mb_substr($value, 0, $maxCharactersPerCell);
+ }
+
+ // Do not wrap text
+ $row->addCell(Cell::fromValue($value, $noWrapTextStyle));
+ }
+ $writer->addRow($row);
+ ++$rowCount;
+ }
// Sheet configuration (AutoFilter, freeze row, better column width)
$sheet = $writer->getCurrentSheet();
$sheet->setAutoFilter(new AutoFilter(0, 1,
- max(count($columnNames) - 1, 0), max(count($rows), 1)));
+ max(count($columnNames) - 1, 0), $rowCount + 1));
$sheet->setSheetView((new SheetView())->setFreezeRow(2));
$sheet->setColumnWidthForRange(24, 1, max(count($columnNames), 1));
+ if ($truncated) {
+ // Add a notice to the sheet if there is truncated data.
+ //
+ // TODO: when the user opens the XLSX, it will open at the first sheet, not at this notice sheet.
+ // Thus the user won't see the notice immediately.
+ // This needs to have a better solution.
+ $writer
+ ->addNewSheetAndMakeItCurrent()
+ ->setName('Notice');
+ $writer->addRow(Row::fromValues(['Some cell values were too long! They were truncated to fit the 32,767 character limit.'], $boldStyle));
+ }
+
$writer->close();
return new \SplFileInfo($filePath);
diff --git a/tests/Fixtures/AppBundle/Controller/ExporterController.php b/tests/Fixtures/AppBundle/Controller/ExporterController.php
index b6d916d5..b4d9b623 100644
--- a/tests/Fixtures/AppBundle/Controller/ExporterController.php
+++ b/tests/Fixtures/AppBundle/Controller/ExporterController.php
@@ -13,6 +13,7 @@
namespace Tests\Fixtures\AppBundle\Controller;
use Doctrine\ORM\QueryBuilder;
+use Omines\DataTablesBundle\Adapter\ArrayAdapter;
use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter;
use Omines\DataTablesBundle\Column\TextColumn;
use Omines\DataTablesBundle\DataTableFactory;
@@ -100,4 +101,62 @@ public function exportEmptyDataTableAction(Request $request, DataTableFactory $d
'datatable' => $table,
]);
}
+
+ /**
+ * This route returns data which does not fit in an Excel cell (cells have a character limit of 32767).
+ */
+ public function exportLongText(Request $request, DataTableFactory $dataTableFactory): Response
+ {
+ $longText = str_repeat('a', 40000);
+
+ $table = $dataTableFactory
+ ->create()
+ ->add('longText', TextColumn::class)
+ ->createAdapter(ArrayAdapter::class, [
+ ['longText' => $longText],
+ ])
+ ->addEventListener(DataTableExporterEvents::PRE_RESPONSE, function (DataTableExporterResponseEvent $e) {
+ $response = $e->getResponse();
+ $response->deleteFileAfterSend(false);
+ $ext = $response->getFile()->getExtension();
+ $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, 'custom_filename.' . $ext);
+ })
+ ->handleRequest($request);
+
+ if ($table->isCallback()) {
+ return $table->getResponse();
+ }
+
+ return $this->render('@App/exporter.html.twig', [
+ 'datatable' => $table,
+ ]);
+ }
+
+ /**
+ * This route returns data with HTML special characters.
+ */
+ public function exportSpecialChars(Request $request, DataTableFactory $dataTableFactory): Response
+ {
+ $table = $dataTableFactory
+ ->create()
+ ->add('specialChars', TextColumn::class)
+ ->createAdapter(ArrayAdapter::class, [
+ ['specialChars' => 'World'],
+ ])
+ ->addEventListener(DataTableExporterEvents::PRE_RESPONSE, function (DataTableExporterResponseEvent $e) {
+ $response = $e->getResponse();
+ $response->deleteFileAfterSend(false);
+ $ext = $response->getFile()->getExtension();
+ $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, 'custom_filename.' . $ext);
+ })
+ ->handleRequest($request);
+
+ if ($table->isCallback()) {
+ return $table->getResponse();
+ }
+
+ return $this->render('@App/exporter.html.twig', [
+ 'datatable' => $table,
+ ]);
+ }
}
diff --git a/tests/Fixtures/routing.yml b/tests/Fixtures/routing.yml
index e4b1dcdc..c62b7621 100644
--- a/tests/Fixtures/routing.yml
+++ b/tests/Fixtures/routing.yml
@@ -44,3 +44,11 @@ exporter:
exporter_empty_datatable:
path: /exporter-empty-datatable
controller: Tests\Fixtures\AppBundle\Controller\ExporterController::exportEmptyDataTableAction
+
+exporter_long_text:
+ path: /exporter-long-text
+ controller: Tests\Fixtures\AppBundle\Controller\ExporterController::exportLongText
+
+exporter_special_chars:
+ path: /exporter-special-chars
+ controller: Tests\Fixtures\AppBundle\Controller\ExporterController::exportSpecialChars
diff --git a/tests/Functional/Exporter/Excel/ExcelOpenSpoutExporterTest.php b/tests/Functional/Exporter/Excel/ExcelOpenSpoutExporterTest.php
index 5394cc5b..e9e4a4e0 100755
--- a/tests/Functional/Exporter/Excel/ExcelOpenSpoutExporterTest.php
+++ b/tests/Functional/Exporter/Excel/ExcelOpenSpoutExporterTest.php
@@ -98,4 +98,36 @@ public function testWithSearch(): void
static::assertEmpty($sheet->getCell('A3')->getFormattedValue());
static::assertEmpty($sheet->getCell('B3')->getFormattedValue());
}
+
+ public function testMaxCellLength(): void
+ {
+ $this->client->request('POST', '/exporter-long-text', [
+ '_dt' => 'dt',
+ '_exporter' => 'excel-openspout',
+ ]);
+
+ /** @var BinaryFileResponse $response */
+ $response = $this->client->getResponse();
+
+ $sheet = IOFactory::load($response->getFile()->getPathname())->getActiveSheet();
+
+ // Value should be truncated to 32767 characters
+ static::assertSame(str_repeat('a', 32767), $sheet->getCell('A2')->getFormattedValue());
+ }
+
+ public function testSpecialChars(): void
+ {
+ $this->client->request('POST', '/exporter-special-chars', [
+ '_dt' => 'dt',
+ '_exporter' => 'excel-openspout',
+ ]);
+
+ /** @var BinaryFileResponse $response */
+ $response = $this->client->getResponse();
+
+ $sheet = IOFactory::load($response->getFile()->getPathname())->getActiveSheet();
+
+ // Value should not contain HTML encoded characters
+ static::assertSame('World', $sheet->getCell('A2')->getFormattedValue());
+ }
}