Skip to content

Commit

Permalink
Changes for the OpenSpout Excel export (#356)
Browse files Browse the repository at this point in the history
* Truncate the value when it is larger than the Excel cell limit
* Do not wrap text
* Decode HTML encoded characters in the value
  • Loading branch information
mhvis authored Oct 4, 2024
1 parent 6c3b3dc commit ef6c686
Show file tree
Hide file tree
Showing 4 changed files with 145 additions and 12 deletions.
58 changes: 46 additions & 12 deletions src/Exporter/Excel/ExcelOpenSpoutExporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
59 changes: 59 additions & 0 deletions tests/Fixtures/AppBundle/Controller/ExporterController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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' => '<?xml version="1.0" encoding="UTF-8"?><hello>World</hello>'],
])
->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,
]);
}
}
8 changes: 8 additions & 0 deletions tests/Fixtures/routing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
32 changes: 32 additions & 0 deletions tests/Functional/Exporter/Excel/ExcelOpenSpoutExporterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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('<?xml version="1.0" encoding="UTF-8"?><hello>World</hello>', $sheet->getCell('A2')->getFormattedValue());
}
}

0 comments on commit ef6c686

Please sign in to comment.