From ef6c686600b64e62fa30b489bbe1a6bd08e6b28d Mon Sep 17 00:00:00 2001 From: Maarten Visscher Date: Fri, 4 Oct 2024 22:13:39 +0200 Subject: [PATCH] Changes for the OpenSpout Excel export (#356) * Truncate the value when it is larger than the Excel cell limit * Do not wrap text * Decode HTML encoded characters in the value --- src/Exporter/Excel/ExcelOpenSpoutExporter.php | 58 ++++++++++++++---- .../Controller/ExporterController.php | 59 +++++++++++++++++++ tests/Fixtures/routing.yml | 8 +++ .../Excel/ExcelOpenSpoutExporterTest.php | 32 ++++++++++ 4 files changed, 145 insertions(+), 12 deletions(-) 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()); + } }