Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Changes for the OpenSpout Excel export #356

Merged
merged 1 commit into from
Oct 4, 2024
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
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());
}
}
Loading