Skip to content

Commit 2ad65fc

Browse files
authored
Merge pull request #4968 from sapientpro/feature/table-header-row-needs-a-data-row
Xlsx writer: a header-row table takes the empty row below it
2 parents df95a86 + d8c46b4 commit 2ad65fc

3 files changed

Lines changed: 145 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ Some earlier branches remain supported and security fixes are applied to them; i
1616
- Support for Excel sparklines (line, column, and win/loss) in Xlsx reader and writer. [Issue #4941](https://github.com/PHPOffice/PhpSpreadsheet/issues/4941)
1717
- Read-only object model for Pivot Tables. Existing pivot tables in an Xlsx file are now parsed into `Worksheet\PivotTable\PivotTable` objects (name, location, source cache definition, and row/column/page/data field layout), accessible via `Worksheet::getPivotTableCollection()` / `getPivotTableByName()`. Pivot tables (their tables, caches and records) are now also preserved through an Xlsx load/save round-trip instead of being silently dropped. [Issue #4534](https://github.com/PHPOffice/PhpSpreadsheet/issues/4534)
1818

19+
### Fixed
20+
21+
- Xlsx writer: a table showing a header row is written over the row below it when its range covers the header row alone. Excel reports such a workbook as unreadable and repairs it by dropping the table; Excel itself writes the extra row, leaving it without a cell, and the writer now does the same. The row is taken only when it is empty — a table that would swallow a row holding something else throws instead.
22+
1923
### Removed
2024

2125
- Nothing yet.

src/PhpSpreadsheet/Writer/Xlsx/Table.php

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace PhpOffice\PhpSpreadsheet\Writer\Xlsx;
44

55
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
6+
use PhpOffice\PhpSpreadsheet\Exception as PhpSpreadsheetException;
67
use PhpOffice\PhpSpreadsheet\Reader\Xlsx\Namespaces;
78
use PhpOffice\PhpSpreadsheet\Shared\XMLWriter;
89
use PhpOffice\PhpSpreadsheet\Worksheet\Table as WorksheetTable;
@@ -31,7 +32,7 @@ public function writeTable(WorksheetTable $table, int $tableRef): string
3132

3233
// Table
3334
$name = 'Table' . $tableRef;
34-
$range = $table->getRange();
35+
$range = $this->rangeWithRoomForData($table);
3536

3637
$objWriter->startElement('table');
3738
$objWriter->writeAttribute('xmlns', Namespaces::MAIN);
@@ -111,4 +112,47 @@ public function writeTable(WorksheetTable $table, int $tableRef): string
111112
// Return
112113
return $objWriter->getData();
113114
}
115+
116+
/**
117+
* The range to write, given that a table showing a header row needs a row of data under it.
118+
*
119+
* Excel reports a workbook whose table covers its header row alone as unreadable, and repairs
120+
* it by dropping the table — the markup is lost without a word. Excel itself never writes such
121+
* a table: asked to make one over a single row of headings, it writes the table over the row
122+
* below as well, and leaves that row empty (its `sheetData` holds no cell for it). This does
123+
* the same, so what is written is what Excel would have written.
124+
*
125+
* The row below is taken only when it holds nothing: a table silently swallowing a row that
126+
* belongs to something else would change what the sheet says, so that case is refused instead.
127+
*
128+
* @throws PhpSpreadsheetException
129+
*/
130+
private function rangeWithRoomForData(WorksheetTable $table): string
131+
{
132+
$range = $table->getRange();
133+
134+
if (!$table->getShowHeaderRow()) {
135+
return $range;
136+
}
137+
138+
[$rangeStart, $rangeEnd] = Coordinate::rangeBoundaries($range);
139+
140+
if ($rangeEnd[1] > $rangeStart[1]) {
141+
return $range;
142+
}
143+
144+
$worksheet = $table->getWorksheet();
145+
$rowBelow = $rangeEnd[1] + 1;
146+
147+
if ($worksheet !== null && $worksheet->getHighestDataRow() >= $rowBelow) {
148+
throw new PhpSpreadsheetException(
149+
'Table ' . $table->getName() . ' shows a header row over ' . $range
150+
. ', which leaves no row for its data, and row ' . $rowBelow
151+
. ' below it is not empty; a table with a header row needs at least 2 rows'
152+
);
153+
}
154+
155+
return Coordinate::stringFromColumnIndex($rangeStart[0]) . $rangeStart[1]
156+
. ':' . Coordinate::stringFromColumnIndex($rangeEnd[0]) . $rowBelow;
157+
}
114158
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpOffice\PhpSpreadsheetTests\Writer\Xlsx;
6+
7+
use PhpOffice\PhpSpreadsheet\Exception as PhpSpreadsheetException;
8+
use PhpOffice\PhpSpreadsheet\Spreadsheet;
9+
use PhpOffice\PhpSpreadsheet\Worksheet\Table;
10+
use PhpOffice\PhpSpreadsheetTests\Functional\AbstractFunctional;
11+
12+
/**
13+
* Excel reports a workbook whose table covers its header row alone as unreadable, and repairs it
14+
* by dropping the table. Excel itself never writes one: asked to make a table over a single row of
15+
* headings it writes the table over the row below as well, leaving that row without a cell. The
16+
* writer does the same — unless that row already holds something.
17+
*/
18+
class TableHeaderRowTest extends AbstractFunctional
19+
{
20+
private ?Spreadsheet $spreadsheet = null;
21+
22+
protected function tearDown(): void
23+
{
24+
if ($this->spreadsheet !== null) {
25+
$this->spreadsheet->disconnectWorksheets();
26+
$this->spreadsheet = null;
27+
}
28+
}
29+
30+
public function testHeaderRowTakesTheEmptyRowBelowIt(): void
31+
{
32+
$this->spreadsheet = new Spreadsheet();
33+
$sheet = $this->spreadsheet->getActiveSheet();
34+
$sheet->fromArray([['Year', 'Country']], null, 'A1');
35+
$sheet->addTable(new Table('A1:B1', 'SalesData'));
36+
37+
$reloaded = $this->writeAndReload($this->spreadsheet, 'Xlsx');
38+
$worksheet = $reloaded->getActiveSheet();
39+
$table = $worksheet->getTableByName('SalesData');
40+
41+
// exactly what Excel writes for a table made over a single row of headings
42+
self::assertInstanceOf(Table::class, $table);
43+
self::assertSame('A1:B2', $table->getRange());
44+
self::assertSame(1, $worksheet->getHighestDataRow(), 'The row taken is left without a cell');
45+
46+
$reloaded->disconnectWorksheets();
47+
}
48+
49+
public function testHeaderRowWillNotSwallowARowThatHoldsSomething(): void
50+
{
51+
$this->spreadsheet = new Spreadsheet();
52+
$sheet = $this->spreadsheet->getActiveSheet();
53+
$sheet->fromArray([['Year', 'Country']], null, 'A1');
54+
$sheet->getCell('A2')->setValue('Total');
55+
$sheet->addTable(new Table('A1:B1', 'SalesData'));
56+
57+
$this->expectException(PhpSpreadsheetException::class);
58+
$this->expectExceptionMessage('needs at least 2 rows');
59+
60+
$this->writeAndReload($this->spreadsheet, 'Xlsx')->disconnectWorksheets();
61+
}
62+
63+
public function testHeaderRowWithOneRowOfDataIsWritten(): void
64+
{
65+
$this->spreadsheet = new Spreadsheet();
66+
$sheet = $this->spreadsheet->getActiveSheet();
67+
$sheet->fromArray([['Year', 'Country'], [2010, 'Belgium']], null, 'A1');
68+
$sheet->addTable(new Table('A1:B2', 'SalesData'));
69+
70+
$reloaded = $this->writeAndReload($this->spreadsheet, 'Xlsx');
71+
$table = $reloaded->getActiveSheet()->getTableByName('SalesData');
72+
73+
self::assertInstanceOf(Table::class, $table);
74+
self::assertSame('A1:B2', $table->getRange());
75+
self::assertTrue($table->getShowHeaderRow());
76+
77+
$reloaded->disconnectWorksheets();
78+
}
79+
80+
public function testASingleRowTableWithoutAHeaderRowIsWritten(): void
81+
{
82+
$this->spreadsheet = new Spreadsheet();
83+
$sheet = $this->spreadsheet->getActiveSheet();
84+
$sheet->fromArray([[2010, 'Belgium']], null, 'A1');
85+
$sheet->addTable((new Table('A1:B1', 'SalesData'))->setShowHeaderRow(false));
86+
87+
$reloaded = $this->writeAndReload($this->spreadsheet, 'Xlsx');
88+
$table = $reloaded->getActiveSheet()->getTableByName('SalesData');
89+
90+
self::assertInstanceOf(Table::class, $table);
91+
self::assertSame('A1:B1', $table->getRange());
92+
self::assertFalse($table->getShowHeaderRow());
93+
94+
$reloaded->disconnectWorksheets();
95+
}
96+
}

0 commit comments

Comments
 (0)