Skip to content

Commit 2952f8c

Browse files
authored
Add style and columnWidth options to the ExcelOpenSpoutExporter (#368)
This adds an extra option on `AbstractColumn` to configure options specific for an exporter. The available options are defined on the exporter using `DataTableExporterInterface::configureColumnOptions()`. The style option allows to set the OpenSpout cell style for a column. The columnWidth sets the column width in the Excel sheet.
1 parent 4977fa1 commit 2952f8c

File tree

11 files changed

+175
-36
lines changed

11 files changed

+175
-36
lines changed

src/Column/AbstractColumn.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ protected function configureOptions(OptionsResolver $resolver): static
107107
'leftExpr' => null,
108108
'operator' => '=',
109109
'rightExpr' => null,
110+
'exporterOptions' => [],
110111
])
111112
->setAllowedTypes('label', ['null', 'string'])
112113
->setAllowedTypes('data', ['null', 'string', 'callable'])
@@ -123,6 +124,8 @@ protected function configureOptions(OptionsResolver $resolver): static
123124
->setAllowedTypes('operator', ['string'])
124125
->setAllowedTypes('leftExpr', ['null', 'string', 'callable'])
125126
->setAllowedTypes('rightExpr', ['null', 'string', 'callable'])
127+
->setAllowedTypes('exporterOptions', ['array'])
128+
->setInfo('exporterOptions', 'Specific exporter options can be specified here, where the key is the exporter name and the value is an array of options.')
126129
;
127130

128131
return $this;
@@ -245,4 +248,13 @@ public function isValidForSearch(mixed $value): bool
245248
{
246249
return true;
247250
}
251+
252+
/**
253+
* @param string $exporterName one of the exporter names as returned by DataTableExporterInterface::getName()
254+
* @return array<string, mixed>
255+
*/
256+
public function getExporterOptions(string $exporterName): array
257+
{
258+
return $this->options['exporterOptions'][$exporterName] ?? [];
259+
}
248260
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
/*
4+
* Symfony DataTables Bundle
5+
* (c) Omines Internetbureau B.V. - https://omines.nl/
6+
*
7+
* For the full copyright and license information, please view the LICENSE
8+
* file that was distributed with this source code.
9+
*/
10+
11+
declare(strict_types=1);
12+
13+
namespace Omines\DataTablesBundle\Exporter;
14+
15+
use Symfony\Component\OptionsResolver\OptionsResolver;
16+
17+
abstract class AbstractDataTableExporter implements DataTableExporterInterface
18+
{
19+
#[\Override]
20+
public function configureColumnOptions(OptionsResolver $resolver): void
21+
{
22+
}
23+
}

src/Exporter/Csv/CsvExporter.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,17 @@
1212

1313
namespace Omines\DataTablesBundle\Exporter\Csv;
1414

15-
use Omines\DataTablesBundle\Exporter\DataTableExporterInterface;
15+
use Omines\DataTablesBundle\Exporter\AbstractDataTableExporter;
1616

1717
/**
1818
* Exports DataTable data to a CSV file.
1919
*
2020
* @author Maxime Pinot <[email protected]>
2121
*/
22-
class CsvExporter implements DataTableExporterInterface
22+
class CsvExporter extends AbstractDataTableExporter
2323
{
24-
public function export(array $columnNames, \Iterator $data): \SplFileInfo
24+
#[\Override]
25+
public function export(array $columnNames, \Iterator $data, array $columnOptions): \SplFileInfo
2526
{
2627
$filePath = sys_get_temp_dir() . '/' . uniqid('dt') . '.csv';
2728

src/Exporter/DataTableExporterCollection.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ public function __construct(\Traversable $exporters)
3535

3636
/**
3737
* Finds a DataTable exporter that matches the given name.
38+
*
39+
* @throws UnknownDataTableExporterException when the exporter with the given name cannot be found
3840
*/
3941
public function getByName(string $name): DataTableExporterInterface
4042
{

src/Exporter/DataTableExporterInterface.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
namespace Omines\DataTablesBundle\Exporter;
1414

15+
use Symfony\Component\OptionsResolver\OptionsResolver;
16+
1517
/**
1618
* Defines a DataTable exporter.
1719
*
@@ -22,9 +24,10 @@ interface DataTableExporterInterface
2224
/**
2325
* Exports the data from the DataTable to a file.
2426
*
25-
* @param mixed[] $columnNames
27+
* @param list<string> $columnNames
28+
* @param list<array<string, mixed>> $columnOptions the parsed options for each column
2629
*/
27-
public function export(array $columnNames, \Iterator $data): \SplFileInfo;
30+
public function export(array $columnNames, \Iterator $data, array $columnOptions): \SplFileInfo;
2831

2932
/**
3033
* The MIME type of the exported file.
@@ -35,4 +38,12 @@ public function getMimeType(): string;
3538
* A unique name to identify the exporter.
3639
*/
3740
public function getName(): string;
41+
42+
/**
43+
* Configures the per-column options available for the exporter.
44+
*
45+
* The manager will resolve the options using the options array set in
46+
* `exporterOptions` of AbstractColumn.
47+
*/
48+
public function configureColumnOptions(OptionsResolver $resolver): void;
3849
}

src/Exporter/DataTableExporterManager.php

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Symfony\Component\HttpFoundation\BinaryFileResponse;
1919
use Symfony\Component\HttpFoundation\Response;
2020
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
21+
use Symfony\Component\OptionsResolver\OptionsResolver;
2122
use Symfony\Contracts\Translation\TranslatorInterface;
2223

2324
/**
@@ -60,27 +61,60 @@ public function setDataTable(DataTable $dataTable): static
6061
}
6162

6263
/**
63-
* @throws UnknownDataTableExporterException
64+
* @throws UnknownDataTableExporterException when the exporter cannot be found
6465
*/
6566
public function getResponse(): Response
6667
{
67-
$exporter = $this->exporterCollection->getByName($this->exporterName);
68-
$file = $exporter->export($this->getColumnNames(), $this->getAllData());
68+
$file = $this->getExport();
6969

7070
$response = new BinaryFileResponse($file);
7171
$response->deleteFileAfterSend(true);
72-
$response->headers->set('Content-Type', $exporter->getMimeType());
72+
$response->headers->set('Content-Type', $this->getExporter()->getMimeType());
7373
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $response->getFile()->getFilename());
7474

7575
$this->dataTable->getEventDispatcher()->dispatch(new DataTableExporterResponseEvent($response), DataTableExporterEvents::PRE_RESPONSE);
7676

7777
return $response;
7878
}
7979

80+
/**
81+
* @throws UnknownDataTableExporterException when the exporter cannot be found
82+
*/
83+
public function getExporter(): DataTableExporterInterface
84+
{
85+
return $this->exporterCollection->getByName($this->exporterName);
86+
}
87+
88+
/**
89+
* @throws UnknownDataTableExporterException when the exporter cannot be found
90+
*/
91+
public function getExport(): \SplFileInfo
92+
{
93+
return $this->getExporter()->export($this->getColumnNames(), $this->getAllData(), $this->getColumnOptions());
94+
}
95+
96+
/**
97+
* @return list<array<string, mixed>>
98+
* @throws UnknownDataTableExporterException when the exporter cannot be found
99+
*/
100+
private function getColumnOptions(): array
101+
{
102+
$resolver = new OptionsResolver();
103+
$this->getExporter()->configureColumnOptions($resolver);
104+
105+
$options = [];
106+
foreach ($this->dataTable->getColumns() as $column) {
107+
// For each column, resolve the exporter options set on that column
108+
$options[] = $resolver->resolve($column->getExporterOptions($this->exporterName));
109+
}
110+
111+
return $options;
112+
}
113+
80114
/**
81115
* The translated column names.
82116
*
83-
* @return string[]
117+
* @return list<string>
84118
*/
85119
private function getColumnNames(): array
86120
{

src/Exporter/Excel/ExcelExporter.php

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
namespace Omines\DataTablesBundle\Exporter\Excel;
1414

15-
use Omines\DataTablesBundle\Exporter\DataTableExporterInterface;
15+
use Omines\DataTablesBundle\Exporter\AbstractDataTableExporter;
1616
use PhpOffice\PhpSpreadsheet\Cell\CellAddress;
1717
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
1818
use PhpOffice\PhpSpreadsheet\Helper;
@@ -25,12 +25,10 @@
2525
*
2626
* @author Maxime Pinot <[email protected]>
2727
*/
28-
class ExcelExporter implements DataTableExporterInterface
28+
class ExcelExporter extends AbstractDataTableExporter
2929
{
30-
/**
31-
* @param mixed[] $columnNames
32-
*/
33-
public function export(array $columnNames, \Iterator $data): \SplFileInfo
30+
#[\Override]
31+
public function export(array $columnNames, \Iterator $data, array $columnOptions): \SplFileInfo
3432
{
3533
$spreadsheet = new Spreadsheet();
3634
$sheet = $spreadsheet->getSheet(0);

src/Exporter/Excel/ExcelOpenSpoutExporter.php

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,57 +12,64 @@
1212

1313
namespace Omines\DataTablesBundle\Exporter\Excel;
1414

15-
use Omines\DataTablesBundle\Exporter\DataTableExporterInterface;
15+
use Omines\DataTablesBundle\Exporter\AbstractDataTableExporter;
1616
use OpenSpout\Common\Entity\Cell;
1717
use OpenSpout\Common\Entity\Row;
1818
use OpenSpout\Common\Entity\Style\Style;
1919
use OpenSpout\Writer\AutoFilter;
2020
use OpenSpout\Writer\XLSX\Entity\SheetView;
2121
use OpenSpout\Writer\XLSX\Writer;
22+
use Symfony\Component\OptionsResolver\OptionsResolver;
2223

2324
/**
2425
* Excel exporter using OpenSpout.
2526
*/
26-
class ExcelOpenSpoutExporter implements DataTableExporterInterface
27+
class ExcelOpenSpoutExporter extends AbstractDataTableExporter
2728
{
2829
/**
29-
* @param list<scalar> $columnNames
30+
* This is an Excel limitation. See: https://support.microsoft.com/en-us/office/excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3.
3031
*/
31-
public function export(array $columnNames, \Iterator $data): \SplFileInfo
32+
public const MAX_CHARACTERS_PER_CELL = 32767;
33+
34+
#[\Override]
35+
public function export(array $columnNames, \Iterator $data, array $columnOptions): \SplFileInfo
3236
{
3337
$filePath = sys_get_temp_dir() . '/' . uniqid('dt') . '.xlsx';
3438

35-
// Style definitions
36-
$noWrapTextStyle = (new Style())->setShouldWrapText(false);
37-
$boldStyle = (new Style())->setFontBold();
38-
3939
$writer = new Writer();
4040
$writer->openToFile($filePath);
4141

42-
// Add header
43-
$writer->addRow(Row::fromValues($columnNames, $boldStyle));
42+
// Header
43+
$writer->addRow(Row::fromValues($columnNames, (new Style())->setFontBold()));
4444

4545
$truncated = false;
46-
$maxCharactersPerCell = 32767; // E.g. https://support.microsoft.com/en-us/office/excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3
4746
$rowCount = 0;
4847

4948
foreach ($data as $rowValues) {
49+
reset($columnOptions);
5050
$row = new Row([]);
5151
foreach ($rowValues as $value) {
52+
$options = current($columnOptions);
53+
if (false === $options) {
54+
throw new \LogicException('Mismatch in number of row values and number of column options');
55+
}
56+
5257
if (is_string($value)) {
5358
// The data that we get may contain rich HTML. But OpenSpout does not support this.
5459
// We just strip all HTML tags and unescape the remaining text.
5560
$value = htmlspecialchars_decode(strip_tags($value), ENT_QUOTES | ENT_SUBSTITUTE);
5661

5762
// Excel has a limit of 32,767 characters per cell
58-
if (mb_strlen($value) > $maxCharactersPerCell) {
63+
if (mb_strlen($value) > static::MAX_CHARACTERS_PER_CELL) {
5964
$truncated = true;
60-
$value = mb_substr($value, 0, $maxCharactersPerCell);
65+
$value = mb_substr($value, 0, static::MAX_CHARACTERS_PER_CELL);
6166
}
6267
}
6368

6469
// Do not wrap text
65-
$row->addCell(Cell::fromValue($value, $noWrapTextStyle));
70+
$row->addCell(Cell::fromValue($value, $this->resolveStyleOption($options['style'], $value)));
71+
72+
next($columnOptions);
6673
}
6774
$writer->addRow($row);
6875
++$rowCount;
@@ -73,7 +80,11 @@ public function export(array $columnNames, \Iterator $data): \SplFileInfo
7380
$sheet->setAutoFilter(new AutoFilter(0, 1,
7481
max(count($columnNames) - 1, 0), $rowCount + 1));
7582
$sheet->setSheetView((new SheetView())->setFreezeRow(2));
76-
$sheet->setColumnWidthForRange(24, 1, max(count($columnNames), 1));
83+
84+
// Column widths
85+
foreach ($columnOptions as $index => $options) {
86+
$sheet->setColumnWidth($options['columnWidth'], $index + 1);
87+
}
7788

7889
if ($truncated) {
7990
// Add a notice to the sheet if there is truncated data.
@@ -84,14 +95,19 @@ public function export(array $columnNames, \Iterator $data): \SplFileInfo
8495
$writer
8596
->addNewSheetAndMakeItCurrent()
8697
->setName('Notice');
87-
$writer->addRow(Row::fromValues(['Some cell values were too long! They were truncated to fit the 32,767 character limit.'], $boldStyle));
98+
$writer->addRow(Row::fromValues(['Some cell values were too long! They were truncated to fit the 32,767 character limit.'], (new Style())->setFontBold()));
8899
}
89100

90101
$writer->close();
91102

92103
return new \SplFileInfo($filePath);
93104
}
94105

106+
private function resolveStyleOption(Style|callable $style, mixed $value): Style
107+
{
108+
return $style instanceof Style ? $style : $style($value);
109+
}
110+
95111
public function getMimeType(): string
96112
{
97113
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
@@ -101,4 +117,17 @@ public function getName(): string
101117
{
102118
return 'excel-openspout';
103119
}
120+
121+
#[\Override]
122+
public function configureColumnOptions(OptionsResolver $resolver): void
123+
{
124+
$resolver
125+
->setDefaults([
126+
'style' => (new Style())->setShouldWrapText(false),
127+
'columnWidth' => 24,
128+
])
129+
->setAllowedTypes('style', [Style::class, 'callable'])
130+
->setAllowedTypes('columnWidth', ['int', 'float'])
131+
;
132+
}
104133
}

tests/Fixtures/AppBundle/Controller/ExporterController.php

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use Omines\DataTablesBundle\DataTableFactory;
2020
use Omines\DataTablesBundle\Exporter\DataTableExporterEvents;
2121
use Omines\DataTablesBundle\Exporter\Event\DataTableExporterResponseEvent;
22+
use OpenSpout\Common\Entity\Style\Style;
2223
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
2324
use Symfony\Component\HttpFoundation\Request;
2425
use Symfony\Component\HttpFoundation\Response;
@@ -38,8 +39,22 @@ public function exportAction(Request $request, DataTableFactory $dataTableFactor
3839
'render' => function (string $value, Person $context) {
3940
return '<a href="http://example.org">' . $value . '</a>';
4041
},
42+
// We also test the exporter specific options
43+
'exporterOptions' => [
44+
'excel-openspout' => [
45+
'style' => (new Style())->setFontItalic(),
46+
'columnWidth' => 20,
47+
],
48+
],
49+
])
50+
->add('lastName', TextColumn::class, [
51+
'exporterOptions' => [
52+
'excel-openspout' => [
53+
'style' => fn (mixed $value) => (new Style())->setFontBold(), // We can also use a callable
54+
'columnWidth' => 30,
55+
],
56+
],
4157
])
42-
->add('lastName', TextColumn::class)
4358
->createAdapter(ORMAdapter::class, [
4459
'entity' => Person::class,
4560
'query' => function (QueryBuilder $builder) {

0 commit comments

Comments
 (0)