Skip to content

Commit ef6c686

Browse files
authored
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
1 parent 6c3b3dc commit ef6c686

File tree

4 files changed

+145
-12
lines changed

4 files changed

+145
-12
lines changed

src/Exporter/Excel/ExcelOpenSpoutExporter.php

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
namespace Omines\DataTablesBundle\Exporter\Excel;
1414

1515
use Omines\DataTablesBundle\Exporter\DataTableExporterInterface;
16+
use OpenSpout\Common\Entity\Cell;
1617
use OpenSpout\Common\Entity\Row;
1718
use OpenSpout\Common\Entity\Style\Style;
1819
use OpenSpout\Writer\AutoFilter;
@@ -28,28 +29,61 @@ public function export(array $columnNames, \Iterator $data): \SplFileInfo
2829
{
2930
$filePath = sys_get_temp_dir() . '/' . uniqid('dt') . '.xlsx';
3031

31-
// Header
32-
$rows = [Row::fromValues($columnNames, (new Style())->setFontBold())];
32+
// Style definitions
33+
$noWrapTextStyle = (new Style())->setShouldWrapText(false);
34+
$boldStyle = (new Style())->setFontBold();
3335

34-
// Data
35-
foreach ($data as $row) {
36-
// Remove HTML tags
37-
$values = array_map('strip_tags', $row);
38-
$rows[] = Row::fromValues($values);
39-
}
40-
41-
// Write rows
4236
$writer = new Writer();
4337
$writer->openToFile($filePath);
44-
$writer->addRows($rows);
38+
39+
// Add header
40+
$writer->addRow(Row::fromValues($columnNames, $boldStyle));
41+
42+
$truncated = false;
43+
$maxCharactersPerCell = 32767; // E.g. https://support.microsoft.com/en-us/office/excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3
44+
$rowCount = 0;
45+
46+
foreach ($data as $rowValues) {
47+
$row = new Row([]);
48+
foreach ($rowValues as $value) {
49+
// I assume that $value is always a string
50+
51+
// The data that we get may contain rich HTML. But OpenSpout does not support this.
52+
// We just strip all HTML tags and unescape the remaining text.
53+
$value = htmlspecialchars_decode(strip_tags($value), ENT_QUOTES | ENT_SUBSTITUTE);
54+
55+
// Excel has a limit of 32,767 characters per cell
56+
if (mb_strlen($value) > $maxCharactersPerCell) {
57+
$truncated = true;
58+
$value = mb_substr($value, 0, $maxCharactersPerCell);
59+
}
60+
61+
// Do not wrap text
62+
$row->addCell(Cell::fromValue($value, $noWrapTextStyle));
63+
}
64+
$writer->addRow($row);
65+
++$rowCount;
66+
}
4567

4668
// Sheet configuration (AutoFilter, freeze row, better column width)
4769
$sheet = $writer->getCurrentSheet();
4870
$sheet->setAutoFilter(new AutoFilter(0, 1,
49-
max(count($columnNames) - 1, 0), max(count($rows), 1)));
71+
max(count($columnNames) - 1, 0), $rowCount + 1));
5072
$sheet->setSheetView((new SheetView())->setFreezeRow(2));
5173
$sheet->setColumnWidthForRange(24, 1, max(count($columnNames), 1));
5274

75+
if ($truncated) {
76+
// Add a notice to the sheet if there is truncated data.
77+
//
78+
// TODO: when the user opens the XLSX, it will open at the first sheet, not at this notice sheet.
79+
// Thus the user won't see the notice immediately.
80+
// This needs to have a better solution.
81+
$writer
82+
->addNewSheetAndMakeItCurrent()
83+
->setName('Notice');
84+
$writer->addRow(Row::fromValues(['Some cell values were too long! They were truncated to fit the 32,767 character limit.'], $boldStyle));
85+
}
86+
5387
$writer->close();
5488

5589
return new \SplFileInfo($filePath);

tests/Fixtures/AppBundle/Controller/ExporterController.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
namespace Tests\Fixtures\AppBundle\Controller;
1414

1515
use Doctrine\ORM\QueryBuilder;
16+
use Omines\DataTablesBundle\Adapter\ArrayAdapter;
1617
use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter;
1718
use Omines\DataTablesBundle\Column\TextColumn;
1819
use Omines\DataTablesBundle\DataTableFactory;
@@ -100,4 +101,62 @@ public function exportEmptyDataTableAction(Request $request, DataTableFactory $d
100101
'datatable' => $table,
101102
]);
102103
}
104+
105+
/**
106+
* This route returns data which does not fit in an Excel cell (cells have a character limit of 32767).
107+
*/
108+
public function exportLongText(Request $request, DataTableFactory $dataTableFactory): Response
109+
{
110+
$longText = str_repeat('a', 40000);
111+
112+
$table = $dataTableFactory
113+
->create()
114+
->add('longText', TextColumn::class)
115+
->createAdapter(ArrayAdapter::class, [
116+
['longText' => $longText],
117+
])
118+
->addEventListener(DataTableExporterEvents::PRE_RESPONSE, function (DataTableExporterResponseEvent $e) {
119+
$response = $e->getResponse();
120+
$response->deleteFileAfterSend(false);
121+
$ext = $response->getFile()->getExtension();
122+
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, 'custom_filename.' . $ext);
123+
})
124+
->handleRequest($request);
125+
126+
if ($table->isCallback()) {
127+
return $table->getResponse();
128+
}
129+
130+
return $this->render('@App/exporter.html.twig', [
131+
'datatable' => $table,
132+
]);
133+
}
134+
135+
/**
136+
* This route returns data with HTML special characters.
137+
*/
138+
public function exportSpecialChars(Request $request, DataTableFactory $dataTableFactory): Response
139+
{
140+
$table = $dataTableFactory
141+
->create()
142+
->add('specialChars', TextColumn::class)
143+
->createAdapter(ArrayAdapter::class, [
144+
['specialChars' => '<?xml version="1.0" encoding="UTF-8"?><hello>World</hello>'],
145+
])
146+
->addEventListener(DataTableExporterEvents::PRE_RESPONSE, function (DataTableExporterResponseEvent $e) {
147+
$response = $e->getResponse();
148+
$response->deleteFileAfterSend(false);
149+
$ext = $response->getFile()->getExtension();
150+
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, 'custom_filename.' . $ext);
151+
})
152+
->handleRequest($request);
153+
154+
if ($table->isCallback()) {
155+
return $table->getResponse();
156+
}
157+
158+
return $this->render('@App/exporter.html.twig', [
159+
'datatable' => $table,
160+
]);
161+
}
103162
}

tests/Fixtures/routing.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,11 @@ exporter:
4444
exporter_empty_datatable:
4545
path: /exporter-empty-datatable
4646
controller: Tests\Fixtures\AppBundle\Controller\ExporterController::exportEmptyDataTableAction
47+
48+
exporter_long_text:
49+
path: /exporter-long-text
50+
controller: Tests\Fixtures\AppBundle\Controller\ExporterController::exportLongText
51+
52+
exporter_special_chars:
53+
path: /exporter-special-chars
54+
controller: Tests\Fixtures\AppBundle\Controller\ExporterController::exportSpecialChars

tests/Functional/Exporter/Excel/ExcelOpenSpoutExporterTest.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,36 @@ public function testWithSearch(): void
9898
static::assertEmpty($sheet->getCell('A3')->getFormattedValue());
9999
static::assertEmpty($sheet->getCell('B3')->getFormattedValue());
100100
}
101+
102+
public function testMaxCellLength(): void
103+
{
104+
$this->client->request('POST', '/exporter-long-text', [
105+
'_dt' => 'dt',
106+
'_exporter' => 'excel-openspout',
107+
]);
108+
109+
/** @var BinaryFileResponse $response */
110+
$response = $this->client->getResponse();
111+
112+
$sheet = IOFactory::load($response->getFile()->getPathname())->getActiveSheet();
113+
114+
// Value should be truncated to 32767 characters
115+
static::assertSame(str_repeat('a', 32767), $sheet->getCell('A2')->getFormattedValue());
116+
}
117+
118+
public function testSpecialChars(): void
119+
{
120+
$this->client->request('POST', '/exporter-special-chars', [
121+
'_dt' => 'dt',
122+
'_exporter' => 'excel-openspout',
123+
]);
124+
125+
/** @var BinaryFileResponse $response */
126+
$response = $this->client->getResponse();
127+
128+
$sheet = IOFactory::load($response->getFile()->getPathname())->getActiveSheet();
129+
130+
// Value should not contain HTML encoded characters
131+
static::assertSame('<?xml version="1.0" encoding="UTF-8"?><hello>World</hello>', $sheet->getCell('A2')->getFormattedValue());
132+
}
101133
}

0 commit comments

Comments
 (0)