davidannebicque/html-to-spreadsheet-bundle
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.
data-xls-* attributesSpreadsheetRenderer service or SpreadsheetTrait for controllersmoney, date, float2, percent2, etc.)<table data-xls-sheet="..."> tagsdata-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 verticallyHorizontal 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 (true or false)data-xls-font-italic="true" - Make text italic (true or false)data-xls-font-underline="single" - Underline text (single, double, or none)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 maxbg: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 columnAuto-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:
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:contact@example.com">Send email</td>
<!-- Email with subject -->
<td data-xls-link="mailto:support@example.com?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:
These default styles can be overridden using data-xls-font-color and data-xls-font-underline attributes.
composer require davidannebicque/html-to-spreadsheet-bundle
Create the file config/packages/html_to_spreadsheet.yaml :
html_to_spreadsheet:
strict: true
include_builtins: true
A 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écimaleshtml_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>
How can I help you explore Laravel packages today?