diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e7c917ec..8c1642ff 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -5182,7 +5182,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..476f187c --- /dev/null +++ b/samples/Table/01_Table.php @@ -0,0 +1,75 @@ +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('A1:D17', 'Sales_Data'); + +// 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/samples/Table/03_Column_Formula.php b/samples/Table/03_Column_Formula.php new file mode 100644 index 00000000..b431af34 --- /dev/null +++ b/samples/Table/03_Column_Formula.php @@ -0,0 +1,71 @@ +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); + +$columnFormula = '=SUM(Sales_Data[[#This Row],[Q1]:[Q4]])'; + +$dataArray = [ + ['Year', 'Country', 'Q1', 'Q2', 'Q3', 'Q4', 'Sales'], + [2010, 'Belgium', 380, 390, 420, 460, $columnFormula], + [2010, 'France', 510, 490, 460, 590, $columnFormula], + [2010, 'Germany', 720, 680, 640, 660, $columnFormula], + [2010, 'Italy', 440, 410, 420, 450, $columnFormula], + [2010, 'Spain', 510, 490, 470, 420, $columnFormula], + [2010, 'UK', 690, 610, 620, 600, $columnFormula], + [2010, 'United States', 790, 730, 860, 850, $columnFormula], + [2011, 'Belgium', 400, 350, 450, 500, $columnFormula], + [2011, 'France', 620, 650, 415, 570, $columnFormula], + [2011, 'Germany', 680, 620, 710, 690, $columnFormula], + [2011, 'Italy', 430, 370, 350, 335, $columnFormula], + [2011, 'Spain', 460, 390, 430, 415, $columnFormula], + [2011, 'UK', 720, 650, 580, 510, $columnFormula], + [2011, 'United States', 800, 700, 900, 950, $columnFormula], +]; + +$spreadsheet->getActiveSheet()->fromArray($dataArray, null, 'A1'); + +// Create Table +$helper->log('Create Table'); +$table = new Table('A1:G15', 'Sales_Data'); +$table->setRange('A1:G15'); + +// Set Column Formula +$table->getColumn('G')->setColumnFormula($columnFormula); + +// 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/ReferenceHelper.php b/src/PhpSpreadsheet/ReferenceHelper.php index c5f57792..4a6ee039 100644 --- a/src/PhpSpreadsheet/ReferenceHelper.php +++ b/src/PhpSpreadsheet/ReferenceHelper.php @@ -7,6 +7,7 @@ use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\Style\Conditional; use PhpOffice\PhpSpreadsheet\Worksheet\AutoFilter; +use PhpOffice\PhpSpreadsheet\Worksheet\Table; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; class ReferenceHelper @@ -497,6 +498,9 @@ class ReferenceHelper // Update worksheet: autofilter $this->adjustAutoFilter($worksheet, $beforeCellAddress, $numberOfColumns); + // Update worksheet: table + $this->adjustTable($worksheet, $beforeCellAddress, $numberOfColumns); + // Update worksheet: freeze pane if ($worksheet->getFreezePane()) { $splitCell = $worksheet->getFreezePane() ?? ''; @@ -1026,6 +1030,85 @@ class ReferenceHelper } while ($startColID !== $endColID); } + private function adjustTable(Worksheet $worksheet, string $beforeCellAddress, int $numberOfColumns): void + { + $tableCollection = $worksheet->getTableCollection(); + + foreach ($tableCollection as $table) { + $tableRange = $table->getRange(); + if (!empty($tableRange)) { + if ($numberOfColumns !== 0) { + $tableColumns = $table->getColumns(); + if (count($tableColumns) > 0) { + $column = ''; + $row = 0; + sscanf($beforeCellAddress, '%[A-Z]%d', $column, $row); + $columnIndex = Coordinate::columnIndexFromString($column); + [$rangeStart, $rangeEnd] = Coordinate::rangeBoundaries($tableRange); + if ($columnIndex <= $rangeEnd[0]) { + if ($numberOfColumns < 0) { + $this->adjustTableDeleteRules($columnIndex, $numberOfColumns, $tableColumns, $table); + } + $startCol = ($columnIndex > $rangeStart[0]) ? $columnIndex : $rangeStart[0]; + + // Shuffle columns in table range + if ($numberOfColumns > 0) { + $this->adjustTableInsert($startCol, $numberOfColumns, $rangeEnd[0], $table); + } else { + $this->adjustTableDelete($startCol, $numberOfColumns, $rangeEnd[0], $table); + } + } + } + } + + $table->setRange($this->updateCellReference($tableRange)); + } + } + } + + private function adjustTableDeleteRules(int $columnIndex, int $numberOfColumns, array $tableColumns, Table $table): void + { + // If we're actually deleting any columns that fall within the table range, + // then we delete any rules for those columns + $deleteColumn = $columnIndex + $numberOfColumns - 1; + $deleteCount = abs($numberOfColumns); + + for ($i = 1; $i <= $deleteCount; ++$i) { + $columnName = Coordinate::stringFromColumnIndex($deleteColumn + 1); + if (isset($tableColumns[$columnName])) { + $table->clearColumn($columnName); + } + ++$deleteColumn; + } + } + + private function adjustTableInsert(int $startCol, int $numberOfColumns, int $rangeEnd, Table $table): void + { + $startColRef = $startCol; + $endColRef = $rangeEnd; + $toColRef = $rangeEnd + $numberOfColumns; + + do { + $table->shiftColumn(Coordinate::stringFromColumnIndex($endColRef), Coordinate::stringFromColumnIndex($toColRef)); + --$endColRef; + --$toColRef; + } while ($startColRef <= $endColRef); + } + + private function adjustTableDelete(int $startCol, int $numberOfColumns, int $rangeEnd, Table $table): void + { + // For delete, we shuffle from beginning to end to avoid overwriting + $startColID = Coordinate::stringFromColumnIndex($startCol); + $toColID = Coordinate::stringFromColumnIndex($startCol + $numberOfColumns); + $endColID = Coordinate::stringFromColumnIndex($rangeEnd + 1); + + do { + $table->shiftColumn($startColID, $toColID); + ++$startColID; + ++$toColID; + } while ($startColID !== $endColID); + } + private function duplicateStylesByColumn(Worksheet $worksheet, int $beforeColumn, int $beforeRow, int $highestRow, int $numberOfColumns): void { $beforeColumnName = Coordinate::stringFromColumnIndex($beforeColumn - 1); diff --git a/src/PhpSpreadsheet/Worksheet/Table.php b/src/PhpSpreadsheet/Worksheet/Table.php new file mode 100644 index 00000000..66839d41 --- /dev/null +++ b/src/PhpSpreadsheet/Worksheet/Table.php @@ -0,0 +1,454 @@ +|string $range + * A simple string containing a Cell range like 'A1:E10' is permitted + * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), + * or an AddressRange object. + * @param string $name (e.g. Table1) + */ + public function __construct($range = '', string $name = '') + { + $this->setRange($range); + $this->setName($name); + $this->style = new TableStyle(); + } + + /** + * Get Table name. + */ + public function getName(): string + { + return $this->name; + } + + /** + * Set Table name. + */ + public function setName(string $name): self + { + $name = trim($name); + + if (!empty($name)) { + if (strlen($name) === 1 && in_array($name, ['C', 'c', 'R', 'r'])) { + throw new PhpSpreadsheetException('The table name is invalid'); + } + if (strlen($name) > 255) { + throw new PhpSpreadsheetException('The table name cannot be longer than 255 characters'); + } + // Check for A1 or R1C1 cell reference notation + if ( + preg_match(Coordinate::A1_COORDINATE_REGEX, $name) || + preg_match('/^R\[?\-?[0-9]*\]?C\[?\-?[0-9]*\]?$/i', $name) + ) { + throw new PhpSpreadsheetException('The table name can\'t be the same as a cell reference'); + } + if (!preg_match('/^[\p{L}_\\\\]/iu', $name)) { + throw new PhpSpreadsheetException('The table name must begin a name with a letter, an underscore character (_), or a backslash (\)'); + } + if (!preg_match('/^[\p{L}_\\\\][\p{L}\p{M}0-9\._]+$/iu', $name)) { + throw new PhpSpreadsheetException('The table name contains invalid characters'); + } + } + + $this->name = $name; + + return $this; + } + + /** + * Get show Header Row. + */ + public function getShowHeaderRow(): bool + { + return $this->showHeaderRow; + } + + /** + * Set show Header Row. + */ + public function setShowHeaderRow(bool $showHeaderRow): self + { + $this->showHeaderRow = $showHeaderRow; + + return $this; + } + + /** + * Get show Totals Row. + */ + public function getShowTotalsRow(): bool + { + return $this->showTotalsRow; + } + + /** + * Set show Totals Row. + */ + public function setShowTotalsRow(bool $showTotalsRow): self + { + $this->showTotalsRow = $showTotalsRow; + + return $this; + } + + /** + * Get Table Range. + */ + public function getRange(): string + { + return $this->range; + } + + /** + * Set Table Cell Range. + * + * @param AddressRange|array|string $range + * A simple string containing a Cell range like 'A1:E10' is permitted + * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), + * or an AddressRange object. + */ + public function setRange($range = ''): self + { + // extract coordinate + if ($range !== '') { + [, $range] = Worksheet::extractSheetTitle(Validations::validateCellRange($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.'); + } + + [$width, $height] = Coordinate::rangeDimension($range); + if ($width < 1 || $height < 2) { + throw new PhpSpreadsheetException('The table range must be at least 1 column and 2 rows'); + } + + $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. + */ + 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. + */ + public function getWorksheet(): ?Worksheet + { + return $this->workSheet; + } + + /** + * Set Table's Worksheet. + */ + public function setWorksheet(?Worksheet $worksheet = null): self + { + if ($this->name !== '' && $worksheet !== null) { + $spreadsheet = $worksheet->getParent(); + $tableName = StringHelper::strToUpper($this->name); + + foreach ($spreadsheet->getWorksheetIterator() as $sheet) { + foreach ($sheet->getTableCollection() as $table) { + if (StringHelper::strToUpper($table->getName()) === $tableName) { + throw new PhpSpreadsheetException("Workbook already contains a table named '{$this->name}'"); + } + } + } + } + + $this->workSheet = $worksheet; + + return $this; + } + + /** + * Get all Table Columns. + * + * @return Table\Column[] + */ + public function getColumns(): array + { + 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 isColumnInRange(string $column): int + { + 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): int + { + return $this->isColumnInRange($column); + } + + /** + * Get a specified Table Column. + * + * @param string $column Column name (e.g. A) + */ + public function getColumn($column): Table\Column + { + $this->isColumnInRange($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) + */ + public function getColumnByOffset($columnOffset): Table\Column + { + [$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 + */ + public function setColumn($columnObjectOrString): self + { + 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->isColumnInRange($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) + */ + public function clearColumn($column): self + { + $this->isColumnInRange($column); + + if (isset($this->columns[$column])) { + unset($this->columns[$column]); + } + + return $this; + } + + /** + * Shift an Table Column Rule to a different column. + * + * Note: This method bypasses validation of the destination column to ensure it is within this Table range. + * Nor does it verify whether any column rule already exists at $toColumn, but will simply override any existing value. + * Use with caution. + * + * @param string $fromColumn Column name (e.g. A) + * @param string $toColumn Column name (e.g. B) + */ + public function shiftColumn($fromColumn, $toColumn): self + { + $fromColumn = strtoupper($fromColumn); + $toColumn = strtoupper($toColumn); + + if (($fromColumn !== null) && (isset($this->columns[$fromColumn])) && ($toColumn !== null)) { + $this->columns[$fromColumn]->setTable(); + $this->columns[$fromColumn]->setColumnIndex($toColumn); + $this->columns[$toColumn] = $this->columns[$fromColumn]; + $this->columns[$toColumn]->setTable($this); + unset($this->columns[$fromColumn]); + + ksort($this->columns); + } + + return $this; + } + + /** + * Get table Style. + */ + public function getStyle(): Table\TableStyle + { + return $this->style; + } + + /** + * Set table Style. + */ + public function setStyle(TableStyle $style): self + { + $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..a7c445f5 --- /dev/null +++ b/src/PhpSpreadsheet/Worksheet/Table/Column.php @@ -0,0 +1,203 @@ +columnIndex = $column; + $this->table = $table; + } + + /** + * Get Table column index as string eg: 'A'. + */ + public function getColumnIndex(): string + { + return $this->columnIndex; + } + + /** + * Set Table column index as string eg: 'A'. + * + * @param string $column Column (e.g. A) + */ + public function setColumnIndex($column): self + { + // Uppercase coordinate + $column = strtoupper($column); + if ($this->table !== null) { + $this->table->isColumnInRange($column); + } + + $this->columnIndex = $column; + + return $this; + } + + /** + * Get show Filter Button. + */ + public function getShowFilterButton(): bool + { + return $this->showFilterButton; + } + + /** + * Set show Filter Button. + */ + public function setShowFilterButton(bool $showFilterButton): self + { + $this->showFilterButton = $showFilterButton; + + return $this; + } + + /** + * Get total Row Label. + */ + public function getTotalsRowLabel(): ?string + { + return $this->totalsRowLabel; + } + + /** + * Set total Row Label. + */ + public function setTotalsRowLabel(string $totalsRowLabel): self + { + $this->totalsRowLabel = $totalsRowLabel; + + return $this; + } + + /** + * Get total Row Function. + */ + public function getTotalsRowFunction(): ?string + { + return $this->totalsRowFunction; + } + + /** + * Set total Row Function. + */ + public function setTotalsRowFunction(string $totalsRowFunction): self + { + $this->totalsRowFunction = $totalsRowFunction; + + return $this; + } + + /** + * Get total Row Formula. + */ + public function getTotalsRowFormula(): ?string + { + return $this->totalsRowFormula; + } + + /** + * Set total Row Formula. + */ + public function setTotalsRowFormula(string $totalsRowFormula): self + { + $this->totalsRowFormula = $totalsRowFormula; + + return $this; + } + + /** + * Get column Formula. + */ + public function getColumnFormula(): ?string + { + return $this->columnFormula; + } + + /** + * Set column Formula. + */ + public function setColumnFormula(string $columnFormula): self + { + $this->columnFormula = $columnFormula; + + return $this; + } + + /** + * Get this Column's Table. + */ + public function getTable(): ?Table + { + return $this->table; + } + + /** + * Set this Column's Table. + */ + public function setTable(?Table $table = null): self + { + $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..78643c72 --- /dev/null +++ b/src/PhpSpreadsheet/Worksheet/Table/TableStyle.php @@ -0,0 +1,230 @@ +theme = $theme; + } + + /** + * Get theme. + */ + public function getTheme(): string + { + return $this->theme; + } + + /** + * Set theme. + */ + public function setTheme(string $theme): self + { + $this->theme = $theme; + + return $this; + } + + /** + * Get show First Column. + */ + public function getShowFirstColumn(): bool + { + return $this->showFirstColumn; + } + + /** + * Set show First Column. + */ + public function setShowFirstColumn(bool $showFirstColumn): self + { + $this->showFirstColumn = $showFirstColumn; + + return $this; + } + + /** + * Get show Last Column. + */ + public function getShowLastColumn(): bool + { + return $this->showLastColumn; + } + + /** + * Set show Last Column. + */ + public function setShowLastColumn(bool $showLastColumn): self + { + $this->showLastColumn = $showLastColumn; + + return $this; + } + + /** + * Get show Row Stripes. + */ + public function getShowRowStripes(): bool + { + return $this->showRowStripes; + } + + /** + * Set show Row Stripes. + */ + public function setShowRowStripes(bool $showRowStripes): self + { + $this->showRowStripes = $showRowStripes; + + return $this; + } + + /** + * Get show Column Stripes. + */ + public function getShowColumnStripes(): bool + { + return $this->showColumnStripes; + } + + /** + * Set show Column Stripes. + */ + public function setShowColumnStripes(bool $showColumnStripes): self + { + $this->showColumnStripes = $showColumnStripes; + + return $this; + } + + /** + * Get this Style's Table. + */ + public function getTable(): ?Table + { + return $this->table; + } + + /** + * Set this Style's Table. + */ + public function setTable(?Table $table = null): self + { + $this->table = $table; + + return $this; + } +} diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index 70994be5..0baf6ecd 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -111,6 +111,13 @@ class Worksheet implements IComparable */ private $chartCollection; + /** + * Collection of Table objects. + * + * @var ArrayObject + */ + private $tableCollection; + /** * Worksheet title. * @@ -375,7 +382,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(); } /** @@ -2095,6 +2105,58 @@ 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; + } + + /** + * Remove Table by name. + * + * @param string $name Table name + * + * @return $this + */ + public function removeTableByName(string $name): self + { + $name = Shared\StringHelper::strToUpper($name); + foreach ($this->tableCollection as $key => $table) { + if (Shared\StringHelper::strToUpper($table->getName()) === $name) { + unset($this->tableCollection[$key]); + } + } + + return $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 38cd01c7..5aca5117 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..67dbd19b --- /dev/null +++ b/src/PhpSpreadsheet/Writer/Xlsx/Table.php @@ -0,0 +1,111 @@ +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()); + } + } + if ($column->getColumnFormula()) { + $objWriter->writeElement('calculatedColumnFormula', $column->getColumnFormula()); + } + + $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..c7edd86a --- /dev/null +++ b/tests/PhpSpreadsheetTests/Worksheet/Table/ColumnTest.php @@ -0,0 +1,91 @@ +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()); + + $formula = '=SUM(Sales_Data[[#This Row],[Q1]:[Q4]])'; + $result = $column->setColumnFormula($formula); + self::assertInstanceOf(Column::class, $result); + self::assertEquals($formula, $column->getColumnFormula()); + } + + 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/RemoveTableTest.php b/tests/PhpSpreadsheetTests/Worksheet/Table/RemoveTableTest.php new file mode 100644 index 00000000..fb2bb939 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Worksheet/Table/RemoveTableTest.php @@ -0,0 +1,38 @@ +getSheet(); + + $table = new Table(self::INITIAL_RANGE); + $table->setName('Table1'); + $sheet->addTable($table); + + self::assertEquals(1, $sheet->getTableCollection()->count()); + + $sheet->removeTableByName('table1'); // case insensitive + self::assertEquals(0, $sheet->getTableCollection()->count()); + } + + public function testRemoveCollection(): void + { + $sheet = $this->getSheet(); + + $table = new Table(self::INITIAL_RANGE); + $table->setName('Table1'); + $sheet->addTable($table); + + self::assertEquals(1, $sheet->getTableCollection()->count()); + + $sheet->removeTableCollection(); + self::assertEquals(0, $sheet->getTableCollection()->count()); + } +} 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..830cc87b --- /dev/null +++ b/tests/PhpSpreadsheetTests/Worksheet/Table/TableStyleTest.php @@ -0,0 +1,45 @@ +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 + { + $table = new Table(self::INITIAL_RANGE); + $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..c7cbee25 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php @@ -0,0 +1,558 @@ +setName($name); + self::assertInstanceOf(Table::class, $result); + self::assertEquals($expected, $table->getName()); + } + + public function validTableNamesProvider(): array + { + return [ + ['', ''], + ['Table_1', 'Table_1'], + ['_table_2', '_table_2'], + ['\table_3', '\table_3'], + [" Table_4 \n", 'Table_4'], + ['table.5', 'table.5'], + ['தமிழ்', 'தமிழ்'], // UTF-8 letters with combined character + ]; + } + + /** + * @dataProvider invalidTableNamesProvider + */ + public function testInvalidTableNames(string $name): void + { + $table = new Table(self::INITIAL_RANGE); + + $this->expectException(PhpSpreadsheetException::class); + + $table->setName($name); + } + + public function invalidTableNamesProvider(): array + { + return [ + ['C'], + ['c'], + ['R'], + ['r'], + ['Z100'], + ['Z$100'], + ['R1C1'], + ['R1C'], + ['R11C11'], + ['123'], + ['=Table'], + ['ிக'], // starting with UTF-8 combined character + [bin2hex(random_bytes(255))], // random string with length greater than 255 + ]; + } + + public function testUniqueTableName(): void + { + $this->expectException(PhpSpreadsheetException::class); + $sheet = $this->getSheet(); + + $table1 = new Table(); + $table1->setName('Table_1'); + $sheet->addTable($table1); + + $table2 = new Table(); + $table2->setName('table_1'); // case insensitive + $sheet->addTable($table2); + } + + public function testVariousSets(): void + { + $table = new Table(self::INITIAL_RANGE); + + $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->addTable($table); + $result = $table->getWorksheet(); + self::assertSame($sheet, $result); + } + + public function testSetWorksheet(): void + { + $table = new Table(self::INITIAL_RANGE); + $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; + $table = new Table(self::INITIAL_RANGE); + + // Result should be the active table range + $result = $table->getRange(); + self::assertEquals($expectedResult, $result); + } + + /** + * @dataProvider validTableRangeProvider + * + * @param AddressRange|array|string $fullRange + * @param string $fullRange + */ + public function testSetRangeValidRange($fullRange, string $actualRange): void + { + $table = new Table(self::INITIAL_RANGE); + + $result = $table->setRange($fullRange); + self::assertInstanceOf(Table::class, $result); + self::assertEquals($actualRange, $table->getRange()); + } + + public function validTableRangeProvider(): array + { + $sheet = $this->getSheet(); + $title = $sheet->getTitle(); + + return [ + ["$title!G1:J512", 'G1:J512'], + ['K1:N20', 'K1:N20'], + [[3, 5, 6, 8], 'C5:F8'], + [new CellRange(new CellAddress('C5', $sheet), new CellAddress('F8', $sheet)), 'C5:F8'], + ]; + } + + public function testClearRange(): void + { + $expectedResult = ''; + $table = new Table(self::INITIAL_RANGE); + + // 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); + } + + /** + * @dataProvider invalidTableRangeProvider + */ + public function testSetRangeInvalidRange(string $range): void + { + $this->expectException(PhpSpreadsheetException::class); + + new Table($range); + } + + public function invalidTableRangeProvider(): array + { + return [ + ['A1'], + ['A1:A1'], + ['B1:A4'], + ['A1:D1'], + ['D1:A1'], + ]; + } + + public function testGetColumnsEmpty(): void + { + // There should be no columns yet defined + $table = new Table(self::INITIAL_RANGE); + $result = $table->getColumns(); + self::assertIsArray($result); + self::assertCount(0, $result); + } + + public function testGetColumnOffset(): void + { + $columnIndexes = [ + 'H' => 0, + 'K' => 3, + 'M' => 5, + ]; + $table = new Table(self::INITIAL_RANGE); + + // 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 testRemoveColumns(): void + { + $sheet = $this->getSheet(); + $sheet->fromArray(range('H', 'O'), null, 'H2'); + $table = new Table(self::INITIAL_RANGE); + $table->getColumn('L')->setShowFilterButton(false); + $sheet->addTable($table); + + $sheet->removeColumn('K', 2); + $result = $table->getRange(); + self::assertEquals('H2:M256', $result); + + // Check that the prop that was set for column L is no longer set + self::assertTrue($table->getColumn('L')->getShowFilterButton()); + } + + public function testRemoveRows(): void + { + $sheet = $this->getSheet(); + $sheet->fromArray(range('H', 'O'), null, 'H2'); + $table = new Table(self::INITIAL_RANGE); + $sheet->addTable($table); + + $sheet->removeRow(42, 128); + $result = $table->getRange(); + self::assertEquals('H2:O128', $result); + } + + public function testInsertColumns(): void + { + $sheet = $this->getSheet(); + $sheet->fromArray(range('H', 'O'), null, 'H2'); + $table = new Table(self::INITIAL_RANGE); + $table->getColumn('N')->setShowFilterButton(false); + $sheet->addTable($table); + + $sheet->insertNewColumnBefore('N', 3); + $result = $table->getRange(); + self::assertEquals('H2:R256', $result); + + // Check that column N no longer has a prop + self::assertTrue($table->getColumn('N')->getShowFilterButton()); + // Check that the prop originally set in column N has been moved to column Q + self::assertFalse($table->getColumn('Q')->getShowFilterButton()); + } + + public function testInsertRows(): void + { + $sheet = $this->getSheet(); + $sheet->fromArray(range('H', 'O'), null, 'H2'); + $table = new Table(self::INITIAL_RANGE); + $sheet->addTable($table); + + $sheet->insertNewRowBefore(3, 4); + $result = $table->getRange(); + self::assertEquals('H2:O260', $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'; + $table = new Table(self::INITIAL_RANGE); + + // 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); + $table = new Table(self::INITIAL_RANGE); + + $invalidColumn = 'A'; + $table->setColumn($invalidColumn); + } + + public function testSetColumnWithColumnObject(): void + { + $expectedResult = 'M'; + $columnObject = new Column($expectedResult); + $table = new Table(self::INITIAL_RANGE); + + // 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'; + $table = new Table(self::INITIAL_RANGE); + $table->setColumn($invalidColumn); + } + + public function testSetColumnWithInvalidDataType(): void + { + $this->expectException(PhpSpreadsheetException::class); + + $table = new Table(self::INITIAL_RANGE); + $invalidColumn = 123.456; + // @phpstan-ignore-next-line + $table->setColumn($invalidColumn); + } + + public function testGetColumns(): void + { + $table = new Table(self::INITIAL_RANGE); + + $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 + { + $table = new Table(self::INITIAL_RANGE); + + $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 + { + $table = new Table(self::INITIAL_RANGE); + + $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 + { + $table = new Table(self::INITIAL_RANGE); + // 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); + $table = new Table(self::INITIAL_RANGE); + + // Clear the range + $table->setRange(''); + $table->getColumn('A'); + } + + public function testClearRangeWithExistingColumns(): void + { + $table = new Table(self::INITIAL_RANGE); + $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 + { + $table = new Table(self::INITIAL_RANGE); + $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->addTable($table); + $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 + { + $table = new Table(self::INITIAL_RANGE); + $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); + } +}