From 92a50d134f3fd765ec353d3398e43de2e178c68f Mon Sep 17 00:00:00 2001 From: aswinkumar863 Date: Thu, 10 Mar 2022 19:54:45 +0530 Subject: [PATCH] Format as Table for Xlsx Initial implementation of Excel's tables feature (i.e. Select Home > Format as Table in Excel App). Tables are similar to AutoFilter but tables have other advantages like named ranges, easy formatting, totals row and header row with filter. Tables can also be converted to charts and pivot tables easily. Usage: $table = new Table(); $table->setName('Sales_Data'); $table->setRange('A1:D17'); $spreadsheet->getActiveSheet()->addTable($table); In this Commit: - Added Table API with initial support for header and totals row. - Added complete styling options for Table. - Added Xlsx Writer for Table. - Added samples. - Covered with unit tests. To be done: - Filter expressions similar to AutoFilter. - Precalucate formulas for totals row (Check sample 2). - Table named ranges in formulas and calculation. --- phpstan-baseline.neon | 2 +- samples/Table/01_Table.php | 77 ++++ samples/Table/02_Table_Total.php | 84 ++++ src/PhpSpreadsheet/Worksheet/Table.php | 407 ++++++++++++++++ src/PhpSpreadsheet/Worksheet/Table/Column.php | 202 ++++++++ .../Worksheet/Table/TableStyle.php | 254 ++++++++++ src/PhpSpreadsheet/Worksheet/Worksheet.php | 60 +++ src/PhpSpreadsheet/Writer/Xlsx.php | 21 +- .../Writer/Xlsx/ContentTypes.php | 10 + src/PhpSpreadsheet/Writer/Xlsx/Rels.php | 14 +- src/PhpSpreadsheet/Writer/Xlsx/Table.php | 107 +++++ src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php | 22 + .../Worksheet/Table/ColumnTest.php | 86 ++++ .../Worksheet/Table/SetupTeardown.php | 54 +++ .../Worksheet/Table/TableStyleTest.php | 47 ++ .../Worksheet/Table/TableTest.php | 433 ++++++++++++++++++ 16 files changed, 1877 insertions(+), 3 deletions(-) create mode 100644 samples/Table/01_Table.php create mode 100644 samples/Table/02_Table_Total.php create mode 100644 src/PhpSpreadsheet/Worksheet/Table.php create mode 100644 src/PhpSpreadsheet/Worksheet/Table/Column.php create mode 100644 src/PhpSpreadsheet/Worksheet/Table/TableStyle.php create mode 100644 src/PhpSpreadsheet/Writer/Xlsx/Table.php create mode 100644 tests/PhpSpreadsheetTests/Worksheet/Table/ColumnTest.php create mode 100644 tests/PhpSpreadsheetTests/Worksheet/Table/SetupTeardown.php create mode 100644 tests/PhpSpreadsheetTests/Worksheet/Table/TableStyleTest.php create mode 100644 tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ea6a7b89..8e91ec87 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -5527,7 +5527,7 @@ parameters: - message: "#^Parameter \\#2 \\$id of method PhpOffice\\\\PhpSpreadsheet\\\\Writer\\\\Xlsx\\\\Rels\\:\\:writeRelationship\\(\\) expects int, string given\\.$#" - count: 4 + count: 5 path: src/PhpSpreadsheet/Writer/Xlsx/Rels.php - diff --git a/samples/Table/01_Table.php b/samples/Table/01_Table.php new file mode 100644 index 00000000..405c07ae --- /dev/null +++ b/samples/Table/01_Table.php @@ -0,0 +1,77 @@ +log('Create new Spreadsheet object'); +$spreadsheet = new Spreadsheet(); + +// Set document properties +$helper->log('Set document properties'); +$spreadsheet->getProperties()->setCreator('aswinkumar863') + ->setLastModifiedBy('aswinkumar863') + ->setTitle('PhpSpreadsheet Table Test Document') + ->setSubject('PhpSpreadsheet Table Test Document') + ->setDescription('Test document for PhpSpreadsheet, generated using PHP classes.') + ->setKeywords('office PhpSpreadsheet php') + ->setCategory('Table'); + +// Create the worksheet +$helper->log('Add data'); + +$spreadsheet->setActiveSheetIndex(0); +$spreadsheet->getActiveSheet()->setCellValue('A1', 'Year') + ->setCellValue('B1', 'Quarter') + ->setCellValue('C1', 'Country') + ->setCellValue('D1', 'Sales'); + +$dataArray = [ + ['2010', 'Q1', 'United States', 790], + ['2010', 'Q2', 'United States', 730], + ['2010', 'Q3', 'United States', 860], + ['2010', 'Q4', 'United States', 850], + ['2011', 'Q1', 'United States', 800], + ['2011', 'Q2', 'United States', 700], + ['2011', 'Q3', 'United States', 900], + ['2011', 'Q4', 'United States', 950], + ['2010', 'Q1', 'Belgium', 380], + ['2010', 'Q2', 'Belgium', 390], + ['2010', 'Q3', 'Belgium', 420], + ['2010', 'Q4', 'Belgium', 460], + ['2011', 'Q1', 'Belgium', 400], + ['2011', 'Q2', 'Belgium', 350], + ['2011', 'Q3', 'Belgium', 450], + ['2011', 'Q4', 'Belgium', 500], +]; + +$spreadsheet->getActiveSheet()->fromArray($dataArray, null, 'A2'); + +// Create Table +$helper->log('Create Table'); +$table = new Table(); +$table->setName('Sales Data'); +$table->setRange('A1:D17'); + +// Create Columns +$table->getColumn('D')->setShowFilterButton(false); + +// Create Table Style +$helper->log('Create Table Style'); +$tableStyle = new TableStyle(); +$tableStyle->setTheme(TableStyle::TABLE_STYLE_MEDIUM2); +$tableStyle->setShowRowStripes(true); +$tableStyle->setShowColumnStripes(true); +$tableStyle->setShowFirstColumn(true); +$tableStyle->setShowLastColumn(true); +$table->setStyle($tableStyle); + +// Add Table to Worksheet +$helper->log('Add Table to Worksheet'); +$spreadsheet->getActiveSheet()->addTable($table); + +// Save +$helper->write($spreadsheet, __FILE__, ['Xlsx']); diff --git a/samples/Table/02_Table_Total.php b/samples/Table/02_Table_Total.php new file mode 100644 index 00000000..26113cb7 --- /dev/null +++ b/samples/Table/02_Table_Total.php @@ -0,0 +1,84 @@ +log('Create new Spreadsheet object'); +$spreadsheet = new Spreadsheet(); + +// Set document properties +$helper->log('Set document properties'); +$spreadsheet->getProperties()->setCreator('aswinkumar863') + ->setLastModifiedBy('aswinkumar863') + ->setTitle('PhpSpreadsheet Table Test Document') + ->setSubject('PhpSpreadsheet Table Test Document') + ->setDescription('Test document for PhpSpreadsheet, generated using PHP classes.') + ->setKeywords('office PhpSpreadsheet php') + ->setCategory('Table'); + +// Create the worksheet +$helper->log('Add data'); + +$spreadsheet->setActiveSheetIndex(0); +$spreadsheet->getActiveSheet()->setCellValue('A1', 'Year') + ->setCellValue('B1', 'Quarter') + ->setCellValue('C1', 'Country') + ->setCellValue('D1', 'Sales'); + +$dataArray = [ + ['2010', 'Q1', 'United States', 790], + ['2010', 'Q2', 'United States', 730], + ['2010', 'Q3', 'United States', 860], + ['2010', 'Q4', 'United States', 850], + ['2011', 'Q1', 'United States', 800], + ['2011', 'Q2', 'United States', 700], + ['2011', 'Q3', 'United States', 900], + ['2011', 'Q4', 'United States', 950], + ['2010', 'Q1', 'Belgium', 380], + ['2010', 'Q2', 'Belgium', 390], + ['2010', 'Q3', 'Belgium', 420], + ['2010', 'Q4', 'Belgium', 460], + ['2011', 'Q1', 'Belgium', 400], + ['2011', 'Q2', 'Belgium', 350], + ['2011', 'Q3', 'Belgium', 450], + ['2011', 'Q4', 'Belgium', 500], +]; + +$spreadsheet->getActiveSheet()->fromArray($dataArray, null, 'A2'); + +// Table +$helper->log('Create Table'); +$table = new Table(); +$table->setName('SalesData'); +$table->setShowTotalsRow(true); +$table->setRange('A1:D18'); // +1 row for totalsRow + +$helper->log('Add Totals Row'); +// Table column label not implemented yet, +$table->getColumn('A')->setTotalsRowLabel('Total'); +// So set the label directly to the cell +$spreadsheet->getActiveSheet()->getCell('A18')->setValue('Total'); + +// Table column function not implemented yet, +$table->getColumn('D')->setTotalsRowFunction('sum'); +// So set the formula directly to the cell +$spreadsheet->getActiveSheet()->getCell('D18')->setValue('=SUBTOTAL(109,SalesData[Sales])'); + +// Add Table to Worksheet +$helper->log('Add Table to Worksheet'); +$spreadsheet->getActiveSheet()->addTable($table); + +// Save +$path = $helper->getFilename(__FILE__); +$writer = IOFactory::createWriter($spreadsheet, 'Xlsx'); + +// Disable precalculation to add table's total row +$writer->setPreCalculateFormulas(false); +$callStartTime = microtime(true); +$writer->save($path); +$helper->logWrite($writer, $path, $callStartTime); +$helper->logEndingNotes(); diff --git a/src/PhpSpreadsheet/Worksheet/Table.php b/src/PhpSpreadsheet/Worksheet/Table.php new file mode 100644 index 00000000..30d659f1 --- /dev/null +++ b/src/PhpSpreadsheet/Worksheet/Table.php @@ -0,0 +1,407 @@ +setRange($range); + $this->setWorksheet($worksheet); + $this->style = new TableStyle(); + } + + /** + * Get Table name. + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Set Table name. + * + * @return $this + */ + public function setName(string $name) + { + $this->name = preg_replace('/\s+/', '_', trim($name)) ?? ''; + + return $this; + } + + /** + * Get show Header Row. + * + * @return bool + */ + public function getShowHeaderRow() + { + return $this->showHeaderRow; + } + + /** + * Set show Header Row. + * + * @return $this + */ + public function setShowHeaderRow(bool $showHeaderRow) + { + $this->showHeaderRow = $showHeaderRow; + + return $this; + } + + /** + * Get show Totals Row. + * + * @return bool + */ + public function getShowTotalsRow() + { + return $this->showTotalsRow; + } + + /** + * Set show Totals Row. + * + * @return $this + */ + public function setShowTotalsRow(bool $showTotalsRow) + { + $this->showTotalsRow = $showTotalsRow; + + return $this; + } + + /** + * Get Table Range. + * + * @return string + */ + public function getRange() + { + return $this->range; + } + + /** + * Set Table Cell Range. + * + * @return $this + */ + public function setRange(string $range): self + { + // extract coordinate + [$worksheet, $range] = Worksheet::extractSheetTitle($range, true); + if (empty($range)) { + // Discard all column rules + $this->columns = []; + $this->range = ''; + + return $this; + } + + if (strpos($range, ':') === false) { + throw new PhpSpreadsheetException('Table must be set on a range of cells.'); + } + + $this->range = $range; + // Discard any column ruless that are no longer valid within this range + [$rangeStart, $rangeEnd] = Coordinate::rangeBoundaries($this->range); + foreach ($this->columns as $key => $value) { + $colIndex = Coordinate::columnIndexFromString($key); + if (($rangeStart[0] > $colIndex) || ($rangeEnd[0] < $colIndex)) { + unset($this->columns[$key]); + } + } + + return $this; + } + + /** + * Set Table Cell Range to max row. + * + * @return $this + */ + public function setRangeToMaxRow(): self + { + if ($this->workSheet !== null) { + $thisrange = $this->range; + $range = preg_replace('/\\d+$/', (string) $this->workSheet->getHighestRow(), $thisrange) ?? ''; + if ($range !== $thisrange) { + $this->setRange($range); + } + } + + return $this; + } + + /** + * Get Table's Worksheet. + * + * @return null|Worksheet + */ + public function getWorksheet() + { + return $this->workSheet; + } + + /** + * Set Table's Worksheet. + * + * @return $this + */ + public function setWorksheet(?Worksheet $worksheet = null) + { + $this->workSheet = $worksheet; + + return $this; + } + + /** + * Get all Table Columns. + * + * @return Table\Column[] + */ + public function getColumns() + { + return $this->columns; + } + + /** + * Validate that the specified column is in the Table range. + * + * @param string $column Column name (e.g. A) + * + * @return int The column offset within the table range + */ + public function testColumnInRange($column) + { + if (empty($this->range)) { + throw new PhpSpreadsheetException('No table range is defined.'); + } + + $columnIndex = Coordinate::columnIndexFromString($column); + [$rangeStart, $rangeEnd] = Coordinate::rangeBoundaries($this->range); + if (($rangeStart[0] > $columnIndex) || ($rangeEnd[0] < $columnIndex)) { + throw new PhpSpreadsheetException('Column is outside of current table range.'); + } + + return $columnIndex - $rangeStart[0]; + } + + /** + * Get a specified Table Column Offset within the defined Table range. + * + * @param string $column Column name (e.g. A) + * + * @return int The offset of the specified column within the table range + */ + public function getColumnOffset($column) + { + return $this->testColumnInRange($column); + } + + /** + * Get a specified Table Column. + * + * @param string $column Column name (e.g. A) + * + * @return Table\Column + */ + public function getColumn($column) + { + $this->testColumnInRange($column); + + if (!isset($this->columns[$column])) { + $this->columns[$column] = new Table\Column($column, $this); + } + + return $this->columns[$column]; + } + + /** + * Get a specified Table Column by it's offset. + * + * @param int $columnOffset Column offset within range (starting from 0) + * + * @return Table\Column + */ + public function getColumnByOffset($columnOffset) + { + [$rangeStart, $rangeEnd] = Coordinate::rangeBoundaries($this->range); + $pColumn = Coordinate::stringFromColumnIndex($rangeStart[0] + $columnOffset); + + return $this->getColumn($pColumn); + } + + /** + * Set Table. + * + * @param string|Table\Column $columnObjectOrString + * A simple string containing a Column ID like 'A' is permitted + * + * @return $this + */ + public function setColumn($columnObjectOrString) + { + if ((is_string($columnObjectOrString)) && (!empty($columnObjectOrString))) { + $column = $columnObjectOrString; + } elseif (is_object($columnObjectOrString) && ($columnObjectOrString instanceof Table\Column)) { + $column = $columnObjectOrString->getColumnIndex(); + } else { + throw new PhpSpreadsheetException('Column is not within the table range.'); + } + $this->testColumnInRange($column); + + if (is_string($columnObjectOrString)) { + $this->columns[$columnObjectOrString] = new Table\Column($columnObjectOrString, $this); + } else { + $columnObjectOrString->setTable($this); + $this->columns[$column] = $columnObjectOrString; + } + ksort($this->columns); + + return $this; + } + + /** + * Clear a specified Table Column. + * + * @param string $column Column name (e.g. A) + * + * @return $this + */ + public function clearColumn($column) + { + $this->testColumnInRange($column); + + if (isset($this->columns[$column])) { + unset($this->columns[$column]); + } + + return $this; + } + + /** + * Get table Style. + * + * @return TableStyle + */ + public function getStyle() + { + return $this->style; + } + + /** + * Set table Style. + * + * @return $this + */ + public function setStyle(TableStyle $style) + { + $this->style = $style; + + return $this; + } + + /** + * Implement PHP __clone to create a deep clone, not just a shallow copy. + */ + public function __clone() + { + $vars = get_object_vars($this); + foreach ($vars as $key => $value) { + if (is_object($value)) { + if ($key === 'workSheet') { + // Detach from worksheet + $this->{$key} = null; + } else { + $this->{$key} = clone $value; + } + } elseif ((is_array($value)) && ($key == 'columns')) { + // The columns array of \PhpOffice\PhpSpreadsheet\Worksheet\Worksheet\Table objects + $this->{$key} = []; + foreach ($value as $k => $v) { + $this->{$key}[$k] = clone $v; + // attach the new cloned Column to this new cloned Table object + $this->{$key}[$k]->setTable($this); + } + } else { + $this->{$key} = $value; + } + } + } + + /** + * toString method replicates previous behavior by returning the range if object is + * referenced as a property of its worksheet. + */ + public function __toString() + { + return (string) $this->range; + } +} diff --git a/src/PhpSpreadsheet/Worksheet/Table/Column.php b/src/PhpSpreadsheet/Worksheet/Table/Column.php new file mode 100644 index 00000000..d9b87d80 --- /dev/null +++ b/src/PhpSpreadsheet/Worksheet/Table/Column.php @@ -0,0 +1,202 @@ +columnIndex = $column; + $this->table = $table; + } + + /** + * Get Table column index as string eg: 'A'. + * + * @return string + */ + public function getColumnIndex() + { + return $this->columnIndex; + } + + /** + * Set Table column index as string eg: 'A'. + * + * @param string $column Column (e.g. A) + * + * @return $this + */ + public function setColumnIndex($column) + { + // Uppercase coordinate + $column = strtoupper($column); + if ($this->table !== null) { + $this->table->testColumnInRange($column); + } + + $this->columnIndex = $column; + + return $this; + } + + /** + * Get show Filter Button. + * + * @return bool + */ + public function getShowFilterButton() + { + return $this->showFilterButton; + } + + /** + * Set show Filter Button. + * + * @return $this + */ + public function setShowFilterButton(bool $showFilterButton) + { + $this->showFilterButton = $showFilterButton; + + return $this; + } + + /** + * Get total Row Label. + * + * @return string + */ + public function getTotalsRowLabel() + { + return $this->totalsRowLabel; + } + + /** + * Set total Row Label. + * + * @return $this + */ + public function setTotalsRowLabel(string $totalsRowLabel) + { + $this->totalsRowLabel = $totalsRowLabel; + + return $this; + } + + /** + * Get total Row Function. + * + * @return string + */ + public function getTotalsRowFunction() + { + return $this->totalsRowFunction; + } + + /** + * Set total Row Function. + * + * @return $this + */ + public function setTotalsRowFunction(string $totalsRowFunction) + { + $this->totalsRowFunction = $totalsRowFunction; + + return $this; + } + + /** + * Get total Row Formula. + * + * @return string + */ + public function getTotalsRowFormula() + { + return $this->totalsRowFormula; + } + + /** + * Set total Row Formula. + * + * @return $this + */ + public function setTotalsRowFormula(string $totalsRowFormula) + { + $this->totalsRowFormula = $totalsRowFormula; + + return $this; + } + + /** + * Get this Column's Table. + * + * @return null|Table + */ + public function getTable() + { + return $this->table; + } + + /** + * Set this Column's Table. + * + * @return $this + */ + public function setTable(?Table $table = null) + { + $this->table = $table; + + return $this; + } +} diff --git a/src/PhpSpreadsheet/Worksheet/Table/TableStyle.php b/src/PhpSpreadsheet/Worksheet/Table/TableStyle.php new file mode 100644 index 00000000..ccf13729 --- /dev/null +++ b/src/PhpSpreadsheet/Worksheet/Table/TableStyle.php @@ -0,0 +1,254 @@ +theme = $theme; + } + + /** + * Get theme. + * + * @return string + */ + public function getTheme() + { + return $this->theme; + } + + /** + * Set theme. + * + * @return $this + */ + public function setTheme(string $theme) + { + $this->theme = $theme; + + return $this; + } + + /** + * Get show First Column. + * + * @return bool + */ + public function getShowFirstColumn() + { + return $this->showFirstColumn; + } + + /** + * Set show First Column. + * + * @return $this + */ + public function setShowFirstColumn(bool $showFirstColumn) + { + $this->showFirstColumn = $showFirstColumn; + + return $this; + } + + /** + * Get show Last Column. + * + * @return bool + */ + public function getShowLastColumn() + { + return $this->showLastColumn; + } + + /** + * Set show Last Column. + * + * @return $this + */ + public function setShowLastColumn(bool $showLastColumn) + { + $this->showLastColumn = $showLastColumn; + + return $this; + } + + /** + * Get show Row Stripes. + * + * @return bool + */ + public function getShowRowStripes() + { + return $this->showRowStripes; + } + + /** + * Set show Row Stripes. + * + * @return $this + */ + public function setShowRowStripes(bool $showRowStripes) + { + $this->showRowStripes = $showRowStripes; + + return $this; + } + + /** + * Get show Column Stripes. + * + * @return bool + */ + public function getShowColumnStripes() + { + return $this->showColumnStripes; + } + + /** + * Set show Column Stripes. + * + * @return $this + */ + public function setShowColumnStripes(bool $showColumnStripes) + { + $this->showColumnStripes = $showColumnStripes; + + return $this; + } + + /** + * Get this Style's Table. + * + * @return null|Table + */ + public function getTable() + { + return $this->table; + } + + /** + * Set this Style's Table. + * + * @return $this + */ + public function setTable(?Table $table = null) + { + $this->table = $table; + + return $this; + } +} diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index 362f20f0..38e77abc 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -107,6 +107,13 @@ class Worksheet implements IComparable */ private $chartCollection; + /** + * Collection of Table objects. + * + * @var ArrayObject + */ + private $tableCollection; + /** * Worksheet title. * @@ -371,7 +378,10 @@ class Worksheet implements IComparable $this->defaultRowDimension = new RowDimension(null); // Default column dimension $this->defaultColumnDimension = new ColumnDimension(null); + // AutoFilter $this->autoFilter = new AutoFilter('', $this); + // Table collection + $this->tableCollection = new ArrayObject(); } /** @@ -2005,6 +2015,56 @@ class Worksheet implements IComparable return $this; } + /** + * Get collection of Tables. + * + * @return ArrayObject + */ + public function getTableCollection() + { + return $this->tableCollection; + } + + /** + * Add Table. + * + * @return $this + */ + public function addTable(Table $table): self + { + $table->setWorksheet($this); + $this->tableCollection[] = $table; + + return $this; + } + + /** + * Add Table Range by using numeric cell coordinates. + * + * @param int $columnIndex1 Numeric column coordinate of the first cell + * @param int $row1 Numeric row coordinate of the first cell + * @param int $columnIndex2 Numeric column coordinate of the second cell + * @param int $row2 Numeric row coordinate of the second cell + * + * @return $this + */ + public function addTableByColumnAndRow($columnIndex1, $row1, $columnIndex2, $row2): self + { + $cellRange = Coordinate::stringFromColumnIndex($columnIndex1) . $row1 . ':' . Coordinate::stringFromColumnIndex($columnIndex2) . $row2; + + return $this->addTable(new Table($cellRange, $this)); + } + + /** + * Remove collection of Tables. + */ + public function removeTableCollection(): self + { + $this->tableCollection = new ArrayObject(); + + return $this; + } + /** * Get Freeze Pane. * diff --git a/src/PhpSpreadsheet/Writer/Xlsx.php b/src/PhpSpreadsheet/Writer/Xlsx.php index 4f506070..1558b251 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx.php +++ b/src/PhpSpreadsheet/Writer/Xlsx.php @@ -25,6 +25,7 @@ use PhpOffice\PhpSpreadsheet\Writer\Xlsx\RelsRibbon; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\RelsVBA; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\StringTable; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Style; +use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Table; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Theme; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Workbook; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Worksheet; @@ -167,6 +168,11 @@ class Xlsx extends BaseWriter */ private $writerPartTheme; + /** + * @var Table + */ + private $writerPartTable; + /** * @var Workbook */ @@ -196,6 +202,7 @@ class Xlsx extends BaseWriter $this->writerPartStringTable = new StringTable($this); $this->writerPartStyle = new Style($this); $this->writerPartTheme = new Theme($this); + $this->writerPartTable = new Table($this); $this->writerPartWorkbook = new Workbook($this); $this->writerPartWorksheet = new Worksheet($this); @@ -271,6 +278,11 @@ class Xlsx extends BaseWriter return $this->writerPartTheme; } + public function getWriterPartTable(): Table + { + return $this->writerPartTable; + } + public function getWriterPartWorkbook(): Workbook { return $this->writerPartWorkbook; @@ -389,10 +401,11 @@ class Xlsx extends BaseWriter } $chartRef1 = 0; + $tableRef1 = 1; // Add worksheet relationships (drawings, ...) for ($i = 0; $i < $this->spreadSheet->getSheetCount(); ++$i) { // Add relationships - $zipContent['xl/worksheets/_rels/sheet' . ($i + 1) . '.xml.rels'] = $this->getWriterPartRels()->writeWorksheetRelationships($this->spreadSheet->getSheet($i), ($i + 1), $this->includeCharts); + $zipContent['xl/worksheets/_rels/sheet' . ($i + 1) . '.xml.rels'] = $this->getWriterPartRels()->writeWorksheetRelationships($this->spreadSheet->getSheet($i), ($i + 1), $this->includeCharts, $tableRef1); // Add unparsedLoadedData $sheetCodeName = $this->spreadSheet->getSheet($i)->getCodeName(); @@ -478,6 +491,12 @@ class Xlsx extends BaseWriter $zipContent['xl/media/' . $image->getIndexedFilename()] = file_get_contents($image->getPath()); } } + + // Add Table parts + $tables = $this->spreadSheet->getSheet($i)->getTableCollection(); + foreach ($tables as $table) { + $zipContent['xl/tables/table' . $tableRef1 . '.xml'] = $this->getWriterPartTable()->writeTable($table, $tableRef1++); + } } // Add media diff --git a/src/PhpSpreadsheet/Writer/Xlsx/ContentTypes.php b/src/PhpSpreadsheet/Writer/Xlsx/ContentTypes.php index f62c14af..acb85b57 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/ContentTypes.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/ContentTypes.php @@ -85,6 +85,16 @@ class ContentTypes extends WriterPart // Shared strings $this->writeOverrideContentType($objWriter, '/xl/sharedStrings.xml', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml'); + // Table + $table = 1; + for ($i = 0; $i < $sheetCount; ++$i) { + $tableCount = $spreadsheet->getSheet($i)->getTableCollection()->count(); + + for ($t = 1; $t <= $tableCount; ++$t) { + $this->writeOverrideContentType($objWriter, '/xl/tables/table' . $table++ . '.xml', 'application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml'); + } + } + // Add worksheet relationship content types $unparsedLoadedData = $spreadsheet->getUnparsedLoadedData(); $chart = 1; diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Rels.php b/src/PhpSpreadsheet/Writer/Xlsx/Rels.php index 5aa87876..238fb5bf 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Rels.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Rels.php @@ -163,10 +163,11 @@ class Rels extends WriterPart * * @param int $worksheetId * @param bool $includeCharts Flag indicating if we should write charts + * @param int $tableRef Table ID * * @return string XML Output */ - public function writeWorksheetRelationships(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $worksheet, $worksheetId = 1, $includeCharts = false) + public function writeWorksheetRelationships(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $worksheet, $worksheetId = 1, $includeCharts = false, $tableRef = 1) { // Create XML writer $objWriter = null; @@ -252,6 +253,17 @@ class Rels extends WriterPart ); } + // Write Table + $tableCount = $worksheet->getTableCollection()->count(); + for ($i = 1; $i <= $tableCount; ++$i) { + $this->writeRelationship( + $objWriter, + '_table_' . $i, + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table', + '../tables/table' . $tableRef++ . '.xml' + ); + } + // Write header/footer relationship? $i = 1; if (count($worksheet->getHeaderFooter()->getImages()) > 0) { diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Table.php b/src/PhpSpreadsheet/Writer/Xlsx/Table.php new file mode 100644 index 00000000..e7adfd3c --- /dev/null +++ b/src/PhpSpreadsheet/Writer/Xlsx/Table.php @@ -0,0 +1,107 @@ +getParentWriter()->getUseDiskCaching()) { + $objWriter = new XMLWriter(XMLWriter::STORAGE_DISK, $this->getParentWriter()->getDiskCachingDirectory()); + } else { + $objWriter = new XMLWriter(XMLWriter::STORAGE_MEMORY); + } + + // XML header + $objWriter->startDocument('1.0', 'UTF-8', 'yes'); + + // Table + $name = 'Table' . $tableRef; + $range = $table->getRange(); + + $objWriter->startElement('table'); + $objWriter->writeAttribute('xml:space', 'preserve'); + $objWriter->writeAttribute('xmlns', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'); + $objWriter->writeAttribute('id', (string) $tableRef); + $objWriter->writeAttribute('name', $name); + $objWriter->writeAttribute('displayName', $table->getName() ?: $name); + $objWriter->writeAttribute('ref', $range); + $objWriter->writeAttribute('headerRowCount', $table->getShowHeaderRow() ? '1' : '0'); + $objWriter->writeAttribute('totalsRowCount', $table->getShowTotalsRow() ? '1' : '0'); + + // Table Boundaries + [$rangeStart, $rangeEnd] = Coordinate::rangeBoundaries($table->getRange()); + + // Table Auto Filter + if ($table->getShowHeaderRow()) { + $objWriter->startElement('autoFilter'); + $objWriter->writeAttribute('ref', $range); + foreach (range($rangeStart[0], $rangeEnd[0]) as $offset => $columnIndex) { + $column = $table->getColumnByOffset($offset); + + if (!$column->getShowFilterButton()) { + $objWriter->startElement('filterColumn'); + $objWriter->writeAttribute('colId', (string) $offset); + $objWriter->writeAttribute('hiddenButton', '1'); + $objWriter->endElement(); + } + } + $objWriter->endElement(); + } + + // Table Columns + $objWriter->startElement('tableColumns'); + $objWriter->writeAttribute('count', (string) ($rangeEnd[0] - $rangeStart[0] + 1)); + foreach (range($rangeStart[0], $rangeEnd[0]) as $offset => $columnIndex) { + $worksheet = $table->getWorksheet(); + if (!$worksheet) { + continue; + } + + $column = $table->getColumnByOffset($offset); + $cell = $worksheet->getCellByColumnAndRow($columnIndex, $rangeStart[1]); + + $objWriter->startElement('tableColumn'); + $objWriter->writeAttribute('id', (string) ($offset + 1)); + $objWriter->writeAttribute('name', $table->getShowHeaderRow() ? $cell->getValue() : 'Column' . ($offset + 1)); + + if ($table->getShowTotalsRow()) { + if ($column->getTotalsRowLabel()) { + $objWriter->writeAttribute('totalsRowLabel', $column->getTotalsRowLabel()); + } + if ($column->getTotalsRowFunction()) { + $objWriter->writeAttribute('totalsRowFunction', $column->getTotalsRowFunction()); + } + } + $objWriter->endElement(); + } + $objWriter->endElement(); + + // Table Styles + $objWriter->startElement('tableStyleInfo'); + $objWriter->writeAttribute('name', $table->getStyle()->getTheme()); + $objWriter->writeAttribute('showFirstColumn', $table->getStyle()->getShowFirstColumn() ? '1' : '0'); + $objWriter->writeAttribute('showLastColumn', $table->getStyle()->getShowLastColumn() ? '1' : '0'); + $objWriter->writeAttribute('showRowStripes', $table->getStyle()->getShowRowStripes() ? '1' : '0'); + $objWriter->writeAttribute('showColumnStripes', $table->getStyle()->getShowColumnStripes() ? '1' : '0'); + $objWriter->endElement(); + + $objWriter->endElement(); + + // Return + return $objWriter->getData(); + } +} diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php index 2cc35a28..eba4c927 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php @@ -120,6 +120,9 @@ class Worksheet extends WriterPart // AlternateContent $this->writeAlternateContent($objWriter, $worksheet); + // Table + $this->writeTable($objWriter, $worksheet); + // ConditionalFormattingRuleExtensionList // (Must be inserted last. Not insert last, an Excel parse error will occur) $this->writeExtLst($objWriter, $worksheet); @@ -993,6 +996,25 @@ class Worksheet extends WriterPart } } + /** + * Write Table. + */ + private function writeTable(XMLWriter $objWriter, PhpspreadsheetWorksheet $worksheet): void + { + $tableCount = $worksheet->getTableCollection()->count(); + + $objWriter->startElement('tableParts'); + $objWriter->writeAttribute('count', (string) $tableCount); + + for ($t = 1; $t <= $tableCount; ++$t) { + $objWriter->startElement('tablePart'); + $objWriter->writeAttribute('r:id', 'rId_table_' . $t); + $objWriter->endElement(); + } + + $objWriter->endElement(); + } + /** * Write PageSetup. */ diff --git a/tests/PhpSpreadsheetTests/Worksheet/Table/ColumnTest.php b/tests/PhpSpreadsheetTests/Worksheet/Table/ColumnTest.php new file mode 100644 index 00000000..195d6e41 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Worksheet/Table/ColumnTest.php @@ -0,0 +1,86 @@ +getSheet(); + $sheet->getCell('G1')->setValue('Heading'); + $sheet->getCell('G2')->setValue(2); + $sheet->getCell('G3')->setValue(3); + $sheet->getCell('G4')->setValue(4); + $sheet->getCell('H1')->setValue('Heading2'); + $sheet->getCell('H2')->setValue(1); + $sheet->getCell('H3')->setValue(2); + $sheet->getCell('H4')->setValue(3); + $this->maxRow = $maxRow = 4; + $table = new Table(); + $table->setRange("G1:H$maxRow"); + + return $table; + } + + public function testVariousGets(): void + { + $table = $this->initTable(); + $column = $table->getColumn('H'); + $result = $column->getColumnIndex(); + self::assertEquals('H', $result); + } + + public function testGetBadColumnIndex(): void + { + $this->expectException(PhpSpreadsheetException::class); + $this->expectExceptionMessage('Column is outside of current table range.'); + $table = $this->initTable(); + $table->getColumn('B'); + } + + public function testSetColumnIndex(): void + { + $table = $this->initTable(); + $column = $table->getColumn('H'); + $column->setShowFilterButton(false); + $expectedResult = 'G'; + + $result = $column->setColumnIndex($expectedResult); + self::assertInstanceOf(Column::class, $result); + + $result = $result->getColumnIndex(); + self::assertEquals($expectedResult, $result); + } + + public function testVariousSets(): void + { + $table = $this->initTable(); + $column = $table->getColumn('H'); + + $result = $column->setShowFilterButton(false); + self::assertInstanceOf(Column::class, $result); + self::assertFalse($column->getShowFilterButton()); + + $label = 'Total'; + $result = $column->setTotalsRowLabel($label); + self::assertInstanceOf(Column::class, $result); + self::assertEquals($label, $column->getTotalsRowLabel()); + + $function = 'sum'; + $result = $column->setTotalsRowFunction($function); + self::assertInstanceOf(Column::class, $result); + self::assertEquals($function, $column->getTotalsRowFunction()); + } + + public function testTable(): void + { + $table = $this->initTable(); + $column = new Column('H'); + $column->setTable($table); + self::assertEquals($table, $column->getTable()); + } +} diff --git a/tests/PhpSpreadsheetTests/Worksheet/Table/SetupTeardown.php b/tests/PhpSpreadsheetTests/Worksheet/Table/SetupTeardown.php new file mode 100644 index 00000000..76e914f7 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Worksheet/Table/SetupTeardown.php @@ -0,0 +1,54 @@ +sheet = null; + if ($this->spreadsheet !== null) { + $this->spreadsheet->disconnectWorksheets(); + $this->spreadsheet = null; + } + } + + protected function getSpreadsheet(): Spreadsheet + { + if ($this->spreadsheet !== null) { + return $this->spreadsheet; + } + $this->spreadsheet = new Spreadsheet(); + + return $this->spreadsheet; + } + + protected function getSheet(): Worksheet + { + if ($this->sheet !== null) { + return $this->sheet; + } + $this->sheet = $this->getSpreadsheet()->getActiveSheet(); + + return $this->sheet; + } +} diff --git a/tests/PhpSpreadsheetTests/Worksheet/Table/TableStyleTest.php b/tests/PhpSpreadsheetTests/Worksheet/Table/TableStyleTest.php new file mode 100644 index 00000000..e3cdaa7c --- /dev/null +++ b/tests/PhpSpreadsheetTests/Worksheet/Table/TableStyleTest.php @@ -0,0 +1,47 @@ +getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + $style = $table->getStyle(); + + $result = $style->setTheme(TableStyle::TABLE_STYLE_DARK1); + self::assertInstanceOf(TableStyle::class, $result); + self::assertEquals(TableStyle::TABLE_STYLE_DARK1, $style->getTheme()); + + $result = $style->setShowFirstColumn(true); + self::assertInstanceOf(TableStyle::class, $result); + self::assertTrue($style->getShowFirstColumn()); + + $result = $style->setShowLastColumn(true); + self::assertInstanceOf(TableStyle::class, $result); + self::assertTrue($style->getShowLastColumn()); + + $result = $style->setShowRowStripes(true); + self::assertInstanceOf(TableStyle::class, $result); + self::assertTrue($style->getShowRowStripes()); + + $result = $style->setShowColumnStripes(true); + self::assertInstanceOf(TableStyle::class, $result); + self::assertTrue($style->getShowColumnStripes()); + } + + public function testTable(): void + { + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + $style = new TableStyle(); + $style->setTable($table); + self::assertEquals($table, $style->getTable()); + } +} diff --git a/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php b/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php new file mode 100644 index 00000000..c4365f7a --- /dev/null +++ b/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php @@ -0,0 +1,433 @@ +getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + + // magic __toString should return the active table range + $result = (string) $table; + self::assertEquals($expectedResult, $result); + } + + public function testVariousSets(): void + { + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + + $result = $table->setName('Table 1'); + self::assertInstanceOf(Table::class, $result); + // Spaces will be converted to underscore + self::assertEquals('Table_1', $table->getName()); + + $result = $table->setShowHeaderRow(false); + self::assertInstanceOf(Table::class, $result); + self::assertFalse($table->getShowHeaderRow()); + + $result = $table->setShowTotalsRow(true); + self::assertInstanceOf(Table::class, $result); + self::assertTrue($table->getShowTotalsRow()); + } + + public function testGetWorksheet(): void + { + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + $result = $table->getWorksheet(); + self::assertSame($sheet, $result); + } + + public function testSetWorksheet(): void + { + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + $spreadsheet = $this->getSpreadsheet(); + $sheet2 = $spreadsheet->createSheet(); + // Setters return the instance to implement the fluent interface + $result = $table->setWorksheet($sheet2); + self::assertInstanceOf(Table::class, $result); + } + + public function testGetRange(): void + { + $expectedResult = self::INITIAL_RANGE; + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + + // Result should be the active table range + $result = $table->getRange(); + self::assertEquals($expectedResult, $result); + } + + public function testSetRange(): void + { + $sheet = $this->getSheet(); + $title = $sheet->getTitle(); + $table = new Table(self::INITIAL_RANGE, $sheet); + $ranges = [ + 'G1:J512' => "$title!G1:J512", + 'K1:N20' => 'K1:N20', + ]; + + foreach ($ranges as $actualRange => $fullRange) { + // Setters return the instance to implement the fluent interface + $result = $table->setRange($fullRange); + self::assertInstanceOf(Table::class, $result); + + // Result should be the new table range + $result = $table->getRange(); + self::assertEquals($actualRange, $result); + } + } + + public function testClearRange(): void + { + $expectedResult = ''; + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + + // Setters return the instance to implement the fluent interface + $result = $table->setRange(''); + self::assertInstanceOf(Table::class, $result); + + // Result should be a clear range + $result = $table->getRange(); + self::assertEquals($expectedResult, $result); + } + + public function testSetRangeInvalidRange(): void + { + $this->expectException(PhpSpreadsheetException::class); + + $expectedResult = 'A1'; + + $sheet = $this->getSheet(); + $table = new Table($expectedResult, $sheet); + } + + public function testGetColumnsEmpty(): void + { + // There should be no columns yet defined + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + $result = $table->getColumns(); + self::assertIsArray($result); + self::assertCount(0, $result); + } + + public function testGetColumnOffset(): void + { + $columnIndexes = [ + 'H' => 0, + 'K' => 3, + 'M' => 5, + ]; + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + + // If we request a specific column by its column ID, we should get an + // integer returned representing the column offset within the range + foreach ($columnIndexes as $columnIndex => $columnOffset) { + $result = $table->getColumnOffset($columnIndex); + self::assertEquals($columnOffset, $result); + } + } + + public function testGetInvalidColumnOffset(): void + { + $this->expectException(PhpSpreadsheetException::class); + + $invalidColumn = 'G'; + $sheet = $this->getSheet(); + $table = new Table(); + $table->setWorksheet($sheet); + + $table->getColumnOffset($invalidColumn); + } + + public function testSetColumnWithString(): void + { + $expectedResult = 'L'; + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + + // Setters return the instance to implement the fluent interface + $result = $table->setColumn($expectedResult); + self::assertInstanceOf(Table::class, $result); + + $result = $table->getColumns(); + // Result should be an array of \PhpOffice\PhpSpreadsheet\Worksheet\Worksheet\Table\Column + // objects for each column we set indexed by the column ID + self::assertIsArray($result); + self::assertCount(1, $result); + self::assertArrayHasKey($expectedResult, $result); + self::assertInstanceOf(Column::class, $result[$expectedResult]); + } + + public function testSetInvalidColumnWithString(): void + { + $this->expectException(PhpSpreadsheetException::class); + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + + $invalidColumn = 'A'; + $table->setColumn($invalidColumn); + } + + public function testSetColumnWithColumnObject(): void + { + $expectedResult = 'M'; + $columnObject = new Column($expectedResult); + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + + // Setters return the instance to implement the fluent interface + $result = $table->setColumn($columnObject); + self::assertInstanceOf(Table::class, $result); + + $result = $table->getColumns(); + // Result should be an array of \PhpOffice\PhpSpreadsheet\Worksheet\Worksheet\Table\Column + // objects for each column we set indexed by the column ID + self::assertIsArray($result); + self::assertCount(1, $result); + self::assertArrayHasKey($expectedResult, $result); + self::assertInstanceOf(Column::class, $result[$expectedResult]); + } + + public function testSetInvalidColumnWithObject(): void + { + $this->expectException(PhpSpreadsheetException::class); + + $invalidColumn = 'E'; + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + $table->setColumn($invalidColumn); + } + + public function testSetColumnWithInvalidDataType(): void + { + $this->expectException(PhpSpreadsheetException::class); + + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + $invalidColumn = 123.456; + // @phpstan-ignore-next-line + $table->setColumn($invalidColumn); + } + + public function testGetColumns(): void + { + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + + $columnIndexes = ['L', 'M']; + + foreach ($columnIndexes as $columnIndex) { + $table->setColumn($columnIndex); + } + + $result = $table->getColumns(); + // Result should be an array of \PhpOffice\PhpSpreadsheet\Worksheet\Worksheet\Table\Column + // objects for each column we set indexed by the column ID + self::assertIsArray($result); + self::assertCount(count($columnIndexes), $result); + foreach ($columnIndexes as $columnIndex) { + self::assertArrayHasKey($columnIndex, $result); + self::assertInstanceOf(Column::class, $result[$columnIndex]); + } + + $table->setRange(''); + self::assertCount(0, $table->getColumns()); + self::assertSame('', $table->getRange()); + } + + public function testGetColumn(): void + { + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + + $columnIndexes = ['L', 'M']; + + foreach ($columnIndexes as $columnIndex) { + $table->setColumn($columnIndex); + } + + // If we request a specific column by its column ID, we should + // get a \PhpOffice\PhpSpreadsheet\Worksheet\Worksheet\Table\Column object returned + foreach ($columnIndexes as $columnIndex) { + $result = $table->getColumn($columnIndex); + self::assertInstanceOf(Column::class, $result); + } + } + + public function testGetColumnByOffset(): void + { + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + + $columnIndexes = [ + 0 => 'H', + 3 => 'K', + 5 => 'M', + ]; + + // If we request a specific column by its offset, we should + // get a \PhpOffice\PhpSpreadsheet\Worksheet\Worksheet\Table\Column object returned + foreach ($columnIndexes as $columnIndex => $columnID) { + $result = $table->getColumnByOffset($columnIndex); + self::assertInstanceOf(Column::class, $result); + self::assertEquals($result->getColumnIndex(), $columnID); + } + } + + public function testGetColumnIfNotSet(): void + { + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + // If we request a specific column by its column ID, we should + // get a \PhpOffice\PhpSpreadsheet\Worksheet\Worksheet\Table\Column object returned + $result = $table->getColumn('K'); + self::assertInstanceOf(Column::class, $result); + } + + public function testGetColumnWithoutRangeSet(): void + { + $this->expectException(\PhpOffice\PhpSpreadsheet\Exception::class); + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + + // Clear the range + $table->setRange(''); + $table->getColumn('A'); + } + + public function testClearRangeWithExistingColumns(): void + { + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + $expectedResult = ''; + + $columnIndexes = ['L', 'M', 'N']; + foreach ($columnIndexes as $columnIndex) { + $table->setColumn($columnIndex); + } + + // Setters return the instance to implement the fluent interface + $result = $table->setRange(''); + self::assertInstanceOf(Table::class, $result); + + // Range should be cleared + $result = $table->getRange(); + self::assertEquals($expectedResult, $result); + + // Column array should be cleared + $result = $table->getColumns(); + self::assertIsArray($result); + self::assertCount(0, $result); + } + + public function testSetRangeWithExistingColumns(): void + { + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + $expectedResult = 'G1:J512'; + + // These columns should be retained + $columnIndexes1 = ['I', 'J']; + foreach ($columnIndexes1 as $columnIndex) { + $table->setColumn($columnIndex); + } + // These columns should be discarded + $columnIndexes2 = ['K', 'L', 'M']; + foreach ($columnIndexes2 as $columnIndex) { + $table->setColumn($columnIndex); + } + + // Setters return the instance to implement the fluent interface + $result = $table->setRange($expectedResult); + self::assertInstanceOf(Table::class, $result); + + // Range should be correctly set + $result = $table->getRange(); + self::assertEquals($expectedResult, $result); + + // Only columns that existed in the original range and that + // still fall within the new range should be retained + $result = $table->getColumns(); + self::assertIsArray($result); + self::assertCount(count($columnIndexes1), $result); + } + + public function testClone(): void + { + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + $columnIndexes = ['L', 'M']; + + foreach ($columnIndexes as $columnIndex) { + $table->setColumn($columnIndex); + } + + $result = clone $table; + self::assertInstanceOf(Table::class, $result); + self::assertSame($table->getRange(), $result->getRange()); + self::assertNull($result->getWorksheet()); + self::assertNotNull($table->getWorksheet()); + self::assertInstanceOf(Worksheet::class, $table->getWorksheet()); + $tableColumns = $table->getColumns(); + $resultColumns = $result->getColumns(); + self::assertIsArray($tableColumns); + self::assertIsArray($resultColumns); + self::assertCount(2, $tableColumns); + self::assertCount(2, $resultColumns); + self::assertArrayHasKey('L', $tableColumns); + self::assertArrayHasKey('L', $resultColumns); + self::assertArrayHasKey('M', $tableColumns); + self::assertArrayHasKey('M', $resultColumns); + self::assertInstanceOf(Column::class, $tableColumns['L']); + self::assertInstanceOf(Column::class, $resultColumns['L']); + self::assertInstanceOf(Column::class, $tableColumns['M']); + self::assertInstanceOf(Column::class, $resultColumns['M']); + } + + public function testNoWorksheet(): void + { + $table = new Table(); + self::assertNull($table->getWorksheet()); + } + + public function testClearColumn(): void + { + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + $columnIndexes = ['J', 'K', 'L', 'M']; + + foreach ($columnIndexes as $columnIndex) { + $table->setColumn($columnIndex); + } + $columns = $table->getColumns(); + self::assertCount(4, $columns); + self::assertArrayHasKey('J', $columns); + self::assertArrayHasKey('K', $columns); + self::assertArrayHasKey('L', $columns); + self::assertArrayHasKey('M', $columns); + $table->clearColumn('K'); + $columns = $table->getColumns(); + self::assertCount(3, $columns); + self::assertArrayHasKey('J', $columns); + self::assertArrayHasKey('L', $columns); + self::assertArrayHasKey('M', $columns); + } +}