First draft of a Symfony bundle to convert HTML tables into Excel (.xlsx), OpenDocument (.ods) or CSV. WIP.
Convert annotated HTML tables into Excel (.xlsx), OpenDocument (.ods) or CSV using a simple, declarative syntax.
This bundle is ideal for developers who want to use Twig + HTML as a DSL to build spreadsheets, without directly using the PhpSpreadsheet API.
- PHP 8.2+
- Symfony 6.4+
- PhpSpreadsheet 4.x|5.x (maybe 3 also compatible, not tested)
- Write spreadsheets using HTML tables and
data-xls-*attributes - One-liner rendering with
SpreadsheetRendererservice orSpreadsheetTraitfor controllers - Auto-generation of Excel sheets, styles, formulas, freezepanes, column widths, images, etc.
- Built-in French-oriented presets (
money,date,float2,percent2, etc.) - Generate XLSX, ODS, CSV with multi-format export support
- Cell styling (background colors, font sizes, borders, protection)
- Image support (local files, data-URI, remote HTTP/HTTPS URLs)
- Multiple sheets from multiple
<table data-xls-sheet="...">tags - Strict mode (invalid attributes → exception)
- Fully extensible (register custom styles, validators, etc.)
data-xls-bg-color="#FF0000"- Set cell background color (hex format)data-xls-font-size="14"- Set font size (numeric value)
data-xls-border="thin"- Set border style (thin,medium,thick,none)data-xls-border-color="#000000"- Set border color (hex format)
data-xls-locked="true"- Lock cell (requires sheet protection)data-xls-locked="false"- Unlock cell for editing
<td data-xls-bg-color="#E7E6E6"
data-xls-font-size="16"
data-xls-border="medium"
data-xls-border-color="FF0000"
data-xls-locked="true">
Protected Cell
</td>Merge cells horizontally (colspan) or vertically (rowspan), or both simultaneously.
data-xls-colspan="N"- Merge N columns horizontallydata-xls-rowspan="N"- Merge N rows vertically
Horizontal Merging (Colspan):
<!-- Merge 3 columns -->
<tr>
<td data-xls-colspan="3">Merged Header</td>
</tr>
<tr>
<td>A</td>
<td>B</td>
<td>C</td>
</tr>Vertical Merging (Rowspan):
<!-- Merge 2 rows -->
<tr>
<td data-xls-rowspan="2">Category</td>
<td>Item 1</td>
</tr>
<tr>
<td>Item 2</td>
</tr>Combined Merging:
<!-- Merge 2 columns and 2 rows (2x2 block) -->
<tr>
<td data-xls-rowspan="2" data-xls-colspan="2">
Large Block
</td>
<td>C1</td>
</tr>
<tr>
<td>C2</td>
</tr>Complex Table Example:
<table data-xls-sheet="Report">
<thead>
<tr>
<th data-xls-colspan="2" data-xls-bg-color="#4472C4">Q1 Results</th>
<th data-xls-colspan="2" data-xls-bg-color="#70AD47">Q2 Results</th>
</tr>
<tr>
<th>Revenue</th><th>Profit</th>
<th>Revenue</th><th>Profit</th>
</tr>
</thead>
<tbody>
<tr>
<td data-xls-rowspan="2" data-xls-align="center">North</td>
<td>100K</td><td>20K</td>
<td>120K</td><td>25K</td>
</tr>
<tr>
<td>110K</td><td>22K</td>
<td>130K</td><td>28K</td>
</tr>
</tbody>
</table>Merged cells can be styled like any other cell:
<td data-xls-rowspan="3"
data-xls-bg-color="#FFCC00"
data-xls-font-bold="true"
data-xls-align="center">
Styled Merged Cell
</td>Control font appearance with these attributes:
data-xls-font-color="#FF0000"- Set font color (hex format)data-xls-font-bold="true"- Make text bold (trueorfalse)data-xls-font-italic="true"- Make text italic (trueorfalse)
data-xls-font-underline="single"- Underline text (single,double, ornone)data-xls-font-name="Arial"- Set font family (e.g., "Arial", "Times New Roman", "Calibri")
<td data-xls-font-color="#0000FF"
data-xls-font-bold="true"
data-xls-font-italic="true"
data-xls-font-underline="single"
data-xls-font-name="Arial">
Styled Text
</td>Apply dynamic formatting based on cell values using a simple syntax:
Syntax: data-xls-conditional="condition|style1|style2..."
value>X- Greater thanvalue<X- Less thanvalue>=X- Greater than or equalvalue<=X- Less than or equalvalue==X- Equal tovalue!=X- Not equal tobetween:min:max- Value between min and max
bg:RRGGBB- Background color (hex without #)font:RRGGBB- Font color (hex without #)bold- Bold text
<!-- Highlight negative values in red -->
<td data-xls-type="number"
data-xls-conditional="value<0|bg:FFCCCC|font:FF0000">
-150.50
</td>
<!-- Highlight high values with bold green -->
<td data-xls-type="number"
data-xls-conditional="value>1000|bg:CCFFCC|bold">
1500.00
</td>
<!-- Highlight values in a range -->
<td data-xls-type="number"
data-xls-conditional="between:100:500|bg:FFFFCC">
250.00
</td>Automatically adjust column widths to fit content. This feature uses PhpSpreadsheet's auto-sizing capability to calculate optimal column widths based on cell content.
Table-level:
data-xls-autosize="true"- Auto-size all columns in the tabledata-xls-autosize="A"- Auto-size a single column (A)data-xls-autosize="A:D"- Auto-size a range of columns (A through D)data-xls-autosize="A,C,E"- Auto-size specific columns (A, C, and E)
Column-level (in <colgroup>):
data-xls-autosize="true"- Auto-size this specific column
Auto-size all columns:
<table data-xls-sheet="Products" data-xls-autosize="true">
<tr>
<td>Product A</td>
<td>This is a much longer description</td>
<td>$99.99</td>
</tr>
</table>Auto-size specific columns:
<table data-xls-sheet="Report" data-xls-autosize="A,C">
<tr>
<td>Name</td>
<td>Fixed width column</td>
<td>Long description text</td>
</tr>
</table>Auto-size columns by range:
<table data-xls-sheet="Data" data-xls-autosize="B:D">
<tr>
<td>ID</td>
<td>Name</td>
<td>Email</td>
<td>Phone</td>
<td>Notes</td>
</tr>
</table>Column-level auto-size with colgroup:
<table data-xls-sheet="Mixed">
<colgroup>
<col data-xls-width="10">
<col data-xls-autosize="true">
<col data-xls-width="30">
<col data-xls-autosize="true">
</colgroup>
<tr>
<td>ID</td>
<td>Variable width name</td>
<td>Fixed</td>
<td>Variable width description</td>
## Cell Comments
Add comments (notes) to cells to provide additional information, instructions, or context. Comments appear as small indicators in cells and display when hovering over them in Excel/LibreOffice.
### Attributes
- `data-xls-comment="text"` - Comment text (required)
- `data-xls-comment-author="name"` - Comment author name (optional)
- `data-xls-comment-width="pixels"` - Comment box width in pixels (optional)
- `data-xls-comment-height="pixels"` - Comment box height in pixels (optional)
- `data-xls-comment-visible="true"` - Make comment always visible (optional, default: false)
### Examples
**Basic comment:**
```html
<td data-xls-comment="This cell needs review">
Important data
</td>Comment with author:
<td data-xls-comment="Please verify this value"
data-xls-comment-author="John Doe">
1,234.56
</td>Comment with custom dimensions:
<td data-xls-comment="This is a longer comment that requires more space to display properly"
data-xls-comment-width="300"
data-xls-comment-height="100">
Complex data
</td>Always visible comment:
<td data-xls-comment="IMPORTANT: Read this first"
data-xls-comment-visible="true">
Critical value
</td>Complete example with all attributes:
<table data-xls-sheet="Review">
<tr>
<td data-xls-comment="This formula needs to be updated for Q2"
data-xls-comment-author="Finance Team"
data-xls-comment-width="250"
data-xls-comment-height="80"
data-xls-comment-visible="true"
data-xls-formula="=SUM(A1:A10)">
</td>
</tr>
</table>Note: Auto-size calculates width based on content after all data is loaded. If both data-xls-width and data-xls-autosize are specified, autosize takes precedence.
Use cases:
- Provide instructions for data entry
- Add review notes or approvals
- Document formula logic
- Flag cells requiring attention
- Add contextual information for collaborators
Export your spreadsheets in multiple formats:
// XLSX (default)
return $factory->streamWorkbook($workbook, 'export.xlsx');
// or explicitly
return $factory->streamWorkbook($workbook, 'export', 'xlsx');
// CSV (exports first sheet only)
return $factory->streamWorkbook($workbook, 'export', 'csv');
// ODS (OpenDocument - LibreOffice compatible)
return $factory->streamWorkbook($workbook, 'export', 'ods');The file extension is automatically added if not present.
Insert images in cells using various sources:
<!-- Local file -->
<td data-xls-image="/path/to/image.png"
data-xls-img-width="100"
data-xls-img-height="50">
</td>
<!-- Remote URL (HTTP/HTTPS) -->
<td data-xls-image="https://example.com/logo.png">
</td>
<!-- Base64 Data-URI -->
<td data-xls-image="data:image/png;base64,iVBORw0KGgo...">
</td>Add clickable links to cells with automatic styling. Links are rendered as blue, underlined text in Excel/ODS files.
data-xls-link="URL"- Hyperlink URL (required)data-xls-link-tooltip="text"- Tooltip displayed on hover (optional)
External URLs:
<td data-xls-link="https://example.com">Visit our website</td>
<td data-xls-link="https://github.com/user/repo">GitHub Repository</td>Internal References (to another sheet/cell):
<!-- Link to another sheet -->
<td data-xls-link="#Sheet2!A1">Go to Sheet2, cell A1</td>
<!-- Link to cell in same sheet -->
<td data-xls-link="#B10">Jump to B10</td>
<!-- Link with spaces in sheet name -->
<td data-xls-link="#'My Data'!C5">Link to "My Data" sheet</td>Email Addresses:
<!-- Simple email -->
<td data-xls-link="mailto:[email protected]">Send email</td>
<!-- Email with subject -->
<td data-xls-link="mailto:[email protected]?subject=Help Request">Contact Support</td>With Tooltips:
<td data-xls-link="https://symfony.com/doc"
data-xls-link-tooltip="Click to view Symfony documentation">
Symfony Docs
</td>Hyperlinks can be combined with other cell styling:
<td data-xls-link="https://example.com"
data-xls-bg-color="#FFFFCC"
data-xls-font-bold="true"
data-xls-link-tooltip="Important link">
Highlighted Link
</td>Hyperlinks are automatically styled with:
- Font color: Blue (#0563C1)
- Text decoration: Underlined
These default styles can be overridden using data-xls-font-color and data-xls-font-underline attributes.
composer require davidannebicque/html-to-spreadsheet-bundleCreate the file config/packages/html_to_spreadsheet.yaml :
html_to_spreadsheet:
strict: true
include_builtins: trueA complete example file with all available styles is provided in:
vendor/davidannebicque/html-to-spreadsheet-bundle/src/Resources/config/html_to_spreadsheet.yaml.dist
th: En-tête de tableau (gras, centré)money: Format monétaire Eurodate: Format date courteint: Nombre entierpercent2: Pourcentage à 2 décimales- ... (voir la documentation complète)
html_to_spreadsheet:
default_styles:
mon_style:
font:
bold: true
color: 'FF0000'
alignment:
horizontal: 'center'Use the SpreadsheetRenderer service or SpreadsheetTrait for a one-liner solution:
<?php
namespace App\Controller;
use Davidannebicque\HtmlToSpreadsheetBundle\Controller\SpreadsheetTrait;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ReportController extends AbstractController
{
use SpreadsheetTrait;
#[Route('/export', name: 'app_export')]
public function export(): Response
{
$data = [
['name' => 'John Doe', 'amount' => 1234.56, 'date' => new \DateTime('2024-02-01')],
['name' => 'Anna Smith', 'amount' => 987.45, 'date' => new \DateTime('2024-02-02')],
];
// One-liner: render template + convert + stream
return $this->renderSpreadsheet(
'reports/export.html.twig',
['lines' => $data],
'export.xlsx'
);
}
}For more control, use the services directly:
<?php
namespace App\Controller;
use Davidannebicque\HtmlToSpreadsheetBundle\Html\HtmlTableInterpreter;
use Davidannebicque\HtmlToSpreadsheetBundle\Html\HtmlToXlsxOptions;
use Davidannebicque\HtmlToSpreadsheetBundle\Spreadsheet\Response\ExcelResponseFactory;
use Davidannebicque\HtmlToSpreadsheetBundle\Spreadsheet\SpreadsheetRenderer;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Environment;
final class AdvancedController extends AbstractController
{
#[Route('/export-advanced', name: 'app_export_advanced')]
public function exportWithRenderer(SpreadsheetRenderer $renderer): Response
{
$data = [
['nom' => 'Doe', 'prenom' => 'John', 'montant' => 1234.56, 'date' => new \DateTime('2024-02-01')],
['nom' => 'Smith', 'prenom' => 'Anna', 'montant' => 987.45, 'date' => new \DateTime('2024-02-02')],
];
// Using the renderer service
return $renderer->renderFromTemplate(
'demo/index.html.twig',
['lignes' => $data],
'export-test.xlsx'
);
}
#[Route('/export-manual', name: 'app_export_manual')]
public function exportManual(
Environment $twig,
HtmlTableInterpreter $interpreter,
ExcelResponseFactory $factory
): Response {
$lines = [
['nom' => 'Doe', 'prenom' => 'John', 'montant' => 1234.56, 'date' => new \DateTime('2024-02-01')],
['nom' => 'Smith', 'prenom' => 'Anna', 'montant' => 987.45, 'date' => new \DateTime('2024-02-02')],
['nom' => 'Lemaire', 'prenom' => 'Lucie', 'montant' => 150.00, 'date' => new \DateTime('2024-02-03')],
];
// 1) Render HTML template
$html = $twig->render('demo/index.html.twig', ['lignes' => $lines]);
// 2) Convert to Spreadsheet
$workbook = $interpreter->fromHtml(
$html,
new HtmlToXlsxOptions(strict: true)
);
// 3) Stream as file download
return $factory->streamWorkbook($workbook, 'export-test.xlsx');
}
#[Route('/create-workbook', name: 'app_create_workbook')]
public function createWorkbook(SpreadsheetRenderer $renderer): Response
{
// Get the workbook object without streaming (for further manipulation)
$workbook = $renderer->createFromTemplate(
'demo/index.html.twig',
['lignes' => [/* data */]]
);
// Manipulate the workbook...
$workbook->getActiveSheet()->getCell('A1')->setValue('Modified');
// Then stream it manually
$factory = new ExcelResponseFactory();
return $factory->streamWorkbook($workbook, 'custom.xlsx');
}
}// XLSX (default)
return $this->renderSpreadsheet('template.html.twig', $data, 'export.xlsx');
// ODS (LibreOffice)
return $this->renderSpreadsheet('template.html.twig', $data, 'export.ods');
// CSV (first sheet only)
return $this->renderSpreadsheet('template.html.twig', $data, 'export.csv');{# templates/test/export.html.twig #}
<div id="workbook">
{# --- Feuille 1 --- #}
<table data-xls-sheet="Résumé"
data-xls-freeze="A2"
data-xls-autofilter="A1:D1"
data-xls-default-col-width="20"
data-xls-gridlines="on">
<colgroup>
<col data-xls-width="25">
<col data-xls-width="20">
<col data-xls-width="20">
<col data-xls-width="18" data-xls-apply="money">
</colgroup>
<thead>
<tr data-xls-apply="th">
<th>Nom</th>
<th>Prénom</th>
<th>Date</th>
<th>Montant (€)</th>
</tr>
</thead>
<tbody>
{% for l in lignes %}
<tr>
<td>{{ l.nom }}</td>
<td>{{ l.prenom }}</td>
{# Date formatée #}
<td data-xls-apply="date" data-xls-type="date">
{{ l.date|date('Y-m-d') }}
</td>
{# Montant format monétaire #}
<td data-xls-apply="money" data-xls-type="number">
{{ l.montant }}
</td>
</tr>
{% endfor %}
{# Total via formule #}
<tr>
<td data-xls-colspan="3" data-xls-align="right"><strong>Total :</strong></td>
<td data-xls-formula="SUM(D2:D{{ 1 + lignes|length }})"
data-xls-apply="money"></td>
</tr>
</tbody>
</table>
{# --- Feuille 2 --- #}
<table data-xls-sheet="Brut"
data-xls-default-col-width="15">
<thead>
<tr data-xls-apply="th">
<th>#</th>
<th>Nom</th>
<th>Prénom</th>
<th>Date brute</th>
<th>Montant brut</th>
</tr>
</thead>
<tbody>
{% for l in lignes %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ l.nom }}</td>
<td>{{ l.prenom }}</td>
<td data-xls-type="date">{{ l.date|date('Y-m-d H:i') }}</td>
<td data-xls-type="number">{{ l.montant }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>