From 92a50d134f3fd765ec353d3398e43de2e178c68f Mon Sep 17 00:00:00 2001 From: aswinkumar863 Date: Thu, 10 Mar 2022 19:54:45 +0530 Subject: [PATCH 01/52] 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); + } +} From 62238bc0118f442ce5157f2f050d14c234304889 Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Wed, 30 Mar 2022 17:02:42 +0200 Subject: [PATCH 02/52] Initial work on deprecating `ByColumnAndRow` methods; and providing functionality to use the basic cellAddress methods to be used with a string cell address (e.g. `C5`); an array of columnId and rowId (e.g. `[3, 5]`) or a new CellAddress object. Current implementation for all methods that take a single cell reference argument: - `setCellValue()` - `setCellValueExplicit()` - `getCell()` - `cellExists()` - `setBreak()` - `freezePane()` - `getComment()` Also introducing a CellRange object to work with similar cases for methods that accept a cell range rather than simply a cell address; and RowRange/ColumnRange objects for those cases. Still need to apply to methods that accept a cell range or single cell: - `mergeCells()` - `unmergeCells()` - `protectCells()` - `unprotectCells()` - `setAutoFilter()` Then there's a few special cases that accept row and column ranges, not simply cell ranges; or a series of cell ranges. --- CHANGELOG.md | 21 ++ src/PhpSpreadsheet/Cell/CellAddress.php | 172 ++++++++++++ src/PhpSpreadsheet/Cell/CellRange.php | 78 ++++++ src/PhpSpreadsheet/Cell/ColumnRange.php | 111 ++++++++ src/PhpSpreadsheet/Cell/RowRange.php | 79 ++++++ src/PhpSpreadsheet/Worksheet/Worksheet.php | 223 +++++++++++----- .../Cell/CellAddressTest.php | 246 ++++++++++++++++++ .../Cell/CellRangeTest.php | 113 ++++++++ .../Cell/ColumnRangeTest.php | 65 +++++ .../PhpSpreadsheetTests/Cell/RowRangeTest.php | 51 ++++ 10 files changed, 1087 insertions(+), 72 deletions(-) create mode 100644 src/PhpSpreadsheet/Cell/CellAddress.php create mode 100644 src/PhpSpreadsheet/Cell/CellRange.php create mode 100644 src/PhpSpreadsheet/Cell/ColumnRange.php create mode 100644 src/PhpSpreadsheet/Cell/RowRange.php create mode 100644 tests/PhpSpreadsheetTests/Cell/CellAddressTest.php create mode 100644 tests/PhpSpreadsheetTests/Cell/CellRangeTest.php create mode 100644 tests/PhpSpreadsheetTests/Cell/ColumnRangeTest.php create mode 100644 tests/PhpSpreadsheetTests/Cell/RowRangeTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c7ae1854..3d20a9cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,27 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Deprecated - All Excel Function implementations in `Calculation\Functions` (including the Error functions) have been moved to dedicated classes for groups of related functions. See the docblocks against all the deprecated methods for details of the new methods to call instead. At some point, these old classes will be deleted. +- Worksheet methods that reference cells "byColumnandRow". All such methods have an equivalent that references the cell by its address (e.g. '`E3'` rather than `5, 3`). + + These functions now accept either a cell address string (`'E3')` or an array with columnId and rowId (`[5, 3]`) or a new `CellAddress` object as their `cellAddress`/`coordinate` argument. + This includes the methods: + - `setCellValueByColumnAndRow()` use the equivalent `setCellValue()` + - `setCellValueExplicitByColumnAndRow()` use the equivalent `setCellValueExplicit()` + - `getCellByColumnAndRow()` use the equivalent `getCell()` + - `cellExistsByColumnAndRow()` use the equivalent `cellExists()` + - `getStyleByColumnAndRow()` use the equivalent `getStyle()` + - `setBreakByColumnAndRow()` use the equivalent `setBreak()` + - `mergeCellsByColumnAndRow()` use the equivalent `mergeCells()` + - `unmergeCellsByColumnAndRow()` use the equivalent `unmergeCells()` + - `protectCellsByColumnAndRow()` use the equivalent `protectCells()` + - `unprotectCellsByColumnAndRow()` use the equivalent `unprotectCells()` + - `setAutoFilterByColumnAndRow()` use the equivalent `setAutoFilter()` + - `freezePaneByColumnAndRow()` use the equivalent `freezePane()` + - `getCommentByColumnAndRow()` use the equivalent `getComment()` + - `setSelectedCellByColumnAndRow()` use the equivalent `setSelectedCells()` + + This change provides more consistency in the methods (not every "by cell address" method has an equivalent "byColumnAndRow" method); + and the "by cell address" methods often provide more flexibility, such as allowing a range of cells, or referencig them by passing the defined name of a named range as the argument. ### Removed diff --git a/src/PhpSpreadsheet/Cell/CellAddress.php b/src/PhpSpreadsheet/Cell/CellAddress.php new file mode 100644 index 00000000..389f09ac --- /dev/null +++ b/src/PhpSpreadsheet/Cell/CellAddress.php @@ -0,0 +1,172 @@ +cellAddress = str_replace('$', '', $cellAddress); + [$this->columnName, $rowId] = Coordinate::coordinateFromString($cellAddress); + $this->rowId = (int) $rowId; + $this->columnId = Coordinate::columnIndexFromString($this->columnName); + $this->worksheet = $worksheet; + } + + /** + * @param mixed $columnId + * @param mixed $rowId + */ + private static function validateColumnAndRow($columnId, $rowId): void + { + $array = [$columnId, $rowId]; + array_walk( + $array, + function ($value): void { + if (!is_numeric($value) || $value <= 0) { + throw new Exception('Row and Column Ids must be positive integer values'); + } + } + ); + } + + /** + * @param mixed $columnId + * @param mixed $rowId + */ + public static function fromColumnAndRow($columnId, $rowId, ?Worksheet $worksheet = null): self + { + self::validateColumnAndRow($columnId, $rowId); + + return new static(Coordinate::stringFromColumnIndex($columnId) . ((string) $rowId), $worksheet); + } + + public static function fromColumnRowArray(array $array, ?Worksheet $worksheet = null): self + { + [$columnId, $rowId] = $array; + + return static::fromColumnAndRow($columnId, $rowId, $worksheet); + } + + /** + * @param mixed $cellAddress + */ + public static function fromCellAddress($cellAddress, ?Worksheet $worksheet = null): self + { + return new static($cellAddress, $worksheet); + } + + /** + * The returned address string will contain the worksheet name as well, if available, + * (ie. if a Worksheet was provided to the constructor). + * e.g. "'Mark''s Worksheet'!C5". + */ + public function fullCellAddress(): string + { + if ($this->worksheet !== null) { + $title = str_replace("'", "''", $this->worksheet->getTitle()); + + return "'{$title}'!{$this->cellAddress}"; + } + + return $this->cellAddress; + } + + public function worksheet(): ?Worksheet + { + return $this->worksheet; + } + + /** + * The returned address string will contain just the column/row address, + * (even if a Worksheet was provided to the constructor). + * e.g. "C5". + */ + public function cellAddress(): string + { + return $this->cellAddress; + } + + public function rowId(): int + { + return $this->rowId; + } + + public function columnId(): int + { + return $this->columnId; + } + + public function columnName(): string + { + return $this->columnName; + } + + public function nextRow(int $offset = 1): self + { + $newRowId = $this->rowId + $offset; + if ($newRowId < 1) { + $newRowId = 1; + } + + return static::fromColumnAndRow($this->columnId, $newRowId); + } + + public function previousRow(int $offset = 1): self + { + return $this->nextRow(0 - $offset); + } + + public function nextColumn(int $offset = 1): self + { + $newColumnId = $this->columnId + $offset; + if ($newColumnId < 1) { + $newColumnId = 1; + } + + return static::fromColumnAndRow($newColumnId, $this->rowId); + } + + public function previousColumn(int $offset = 1): self + { + return $this->nextColumn(0 - $offset); + } + + /** + * The returned address string will contain the worksheet name as well, if available, + * (ie. if a Worksheet was provided to the constructor). + * e.g. "'Mark''s Worksheet'!C5". + */ + public function __toString() + { + return $this->fullCellAddress(); + } +} diff --git a/src/PhpSpreadsheet/Cell/CellRange.php b/src/PhpSpreadsheet/Cell/CellRange.php new file mode 100644 index 00000000..4880fa6c --- /dev/null +++ b/src/PhpSpreadsheet/Cell/CellRange.php @@ -0,0 +1,78 @@ +validateFromTo($from, $to); + } + + private function validateFromTo(CellAddress $from, CellAddress $to): void + { + // Identify actual top-left and bottom-right values (in case we've been given top-right and bottom-left) + $firstColumn = min($from->columnId(), $to->columnId()); + $firstRow = min($from->rowId(), $to->rowId()); + $lastColumn = max($from->columnId(), $to->columnId()); + $lastRow = max($from->rowId(), $to->rowId()); + + $fromWorksheet = $from->worksheet(); + $toWorksheet = $to->worksheet(); + $this->validateWorksheets($fromWorksheet, $toWorksheet); + + $this->from = CellAddress::fromColumnAndRow($firstColumn, $firstRow, $fromWorksheet); + $this->to = CellAddress::fromColumnAndRow($lastColumn, $lastRow, $toWorksheet); + } + + private function validateWorksheets(?Worksheet $fromWorksheet, ?Worksheet $toWorksheet): void + { + if ($fromWorksheet !== null && $toWorksheet !== null) { + // We could simply compare worksheets rather than worksheet titles; but at some point we may introduce + // support for 3d ranges; and at that point we drop this check and let the validation fall through + // to the check for same workbook; but unless we check on titles, this test will also detect if the + // worksheets are in different spreadsheets, and the next check will never execute or throw its + // own exception. + if ($fromWorksheet->getTitle() !== $toWorksheet->getTitle()) { + throw new Exception('3d Cell Ranges are not supported'); + } elseif ($fromWorksheet->getParent() !== $toWorksheet->getParent()) { + throw new Exception('Worksheets must be in the same spreadsheet'); + } + } + } + + public function from(): CellAddress + { + return $this->from; + } + + public function to(): CellAddress + { + return $this->to; + } + + public function __toString(): string + { + if ($this->from->cellAddress() === $this->to->cellAddress()) { + return "{$this->from->fullCellAddress()}"; + } + + $fromAddress = $this->from->fullCellAddress(); + $toAddress = $this->to->cellAddress(); + + return "{$fromAddress}:{$toAddress}"; + } +} diff --git a/src/PhpSpreadsheet/Cell/ColumnRange.php b/src/PhpSpreadsheet/Cell/ColumnRange.php new file mode 100644 index 00000000..d38caea8 --- /dev/null +++ b/src/PhpSpreadsheet/Cell/ColumnRange.php @@ -0,0 +1,111 @@ +validateFromTo( + Coordinate::columnIndexFromString($from), + Coordinate::columnIndexFromString($to ?? $from) + ); + $this->worksheet = $worksheet; + } + + public static function fromColumnIndexes(int $from, int $to, ?Worksheet $worksheet = null): self + { + return new self(Coordinate::stringFromColumnIndex($from), Coordinate::stringFromColumnIndex($to), $worksheet); + } + + /** + * @param array $array + */ + public static function fromArray(array $array, ?Worksheet $worksheet = null): self + { + array_walk( + $array, + function (&$column): void { + $column = is_numeric($column) ? Coordinate::stringFromColumnIndex((int) $column) : $column; + } + ); + /** @var string $from */ + /** @var string $to */ + [$from, $to] = $array; + + return new self($from, $to, $worksheet); + } + + private function validateFromTo(int $from, int $to): void + { + // Identify actual top and bottom values (in case we've been given bottom and top) + $this->from = min($from, $to); + $this->to = max($from, $to); + } + + public function columnCount(): int + { + return $this->to - $this->from + 1; + } + + public function from(): string + { + return Coordinate::stringFromColumnIndex($this->from); + } + + public function to(): string + { + return Coordinate::stringFromColumnIndex($this->to); + } + + public function fromIndex(): int + { + return $this->from; + } + + public function toIndex(): int + { + return $this->to; + } + + public function toCellRange(): CellRange + { + return new CellRange( + CellAddress::fromColumnAndRow($this->from, 1, $this->worksheet), + CellAddress::fromColumnAndRow($this->to, self::MAX_ROW) + ); + } + + public function __toString(): string + { + $from = $this->from(); + $to = $this->to(); + + if ($this->worksheet !== null) { + $title = str_replace("'", "''", $this->worksheet->getTitle()); + + return "'{$title}'!{$from}:{$to}"; + } + + return "{$from}:{$to}"; + } +} diff --git a/src/PhpSpreadsheet/Cell/RowRange.php b/src/PhpSpreadsheet/Cell/RowRange.php new file mode 100644 index 00000000..07190702 --- /dev/null +++ b/src/PhpSpreadsheet/Cell/RowRange.php @@ -0,0 +1,79 @@ +validateFromTo($from, $to ?? $from); + $this->worksheet = $worksheet; + } + + public static function fromArray(array $array, ?Worksheet $worksheet = null): self + { + [$from, $to] = $array; + + return new self($from, $to, $worksheet); + } + + private function validateFromTo(int $from, int $to): void + { + // Identify actual top and bottom values (in case we've been given bottom and top) + $this->from = min($from, $to); + $this->to = max($from, $to); + } + + public function from(): int + { + return $this->from; + } + + public function to(): int + { + return $this->to; + } + + public function rowCount(): int + { + return $this->to - $this->from + 1; + } + + public function toCellRange(): CellRange + { + return new CellRange( + CellAddress::fromColumnAndRow(Coordinate::columnIndexFromString('A'), $this->from, $this->worksheet), + CellAddress::fromColumnAndRow(Coordinate::columnIndexFromString(self::MAX_COLUMN), $this->to) + ); + } + + public function __toString(): string + { + if ($this->worksheet !== null) { + $title = str_replace("'", "''", $this->worksheet->getTitle()); + + return "'{$title}'!{$this->from}:{$this->to}"; + } + + return "{$this->from}:{$this->to}"; + } +} diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index d4e1e928..54fcc0de 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -5,6 +5,8 @@ namespace PhpOffice\PhpSpreadsheet\Worksheet; use ArrayObject; use PhpOffice\PhpSpreadsheet\Calculation\Calculation; use PhpOffice\PhpSpreadsheet\Cell\Cell; +use PhpOffice\PhpSpreadsheet\Cell\CellAddress; +use PhpOffice\PhpSpreadsheet\Cell\CellRange; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\Cell\DataValidation; @@ -1103,17 +1105,44 @@ class Worksheet implements IComparable return $this->cellCollection->getHighestRowAndColumn(); } + /** + * Validate a cell address. + * + * @param null|array|CellAddress|string $cellAddress Coordinate of the cell as a string, eg: 'C5'; + * or as an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object. + */ + protected function validateCellAddress($cellAddress, bool $allowNull = false): ?string + { + if (is_string($cellAddress) || ($cellAddress === null && $allowNull === true)) { + if ($cellAddress === null) { + return null; + } + [$worksheet, $address] = self::extractSheetTitle($cellAddress, true); + + return empty($worksheet) ? strtoupper($address) : $worksheet . '!' . strtoupper($address); + } + + if (is_array($cellAddress)) { + $cellAddress = CellAddress::fromColumnRowArray($cellAddress); + } + + return (string) $cellAddress; + } + /** * Set a cell value. * - * @param string $coordinate Coordinate of the cell, eg: 'A1' - * @param mixed $value Value of the cell + * @param array|CellAddress|string $coordinate Coordinate of the cell as a string, eg: 'C5'; + * or as an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object. + * @param mixed $value Value for the cell * * @return $this */ public function setCellValue($coordinate, $value) { - $this->getCell($coordinate)->setValue($value); + /** @var string $cellAddress */ + $cellAddress = $this->validateCellAddress($coordinate); + $this->getCell($cellAddress)->setValue($value); return $this; } @@ -1121,6 +1150,10 @@ class Worksheet implements IComparable /** * Set a cell value by using numeric cell coordinates. * + * @Deprecated 1.23.0 + * Use the setCellValue() method with a cell address such as 'C5' instead;, + * or passing in an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object. + * * @param int $columnIndex Numeric column coordinate of the cell * @param int $row Numeric row coordinate of the cell * @param mixed $value Value of the cell @@ -1129,7 +1162,7 @@ class Worksheet implements IComparable */ public function setCellValueByColumnAndRow($columnIndex, $row, $value) { - $this->getCellByColumnAndRow($columnIndex, $row)->setValue($value); + $this->getCell([$columnIndex, $row])->setValue($value); return $this; } @@ -1137,7 +1170,8 @@ class Worksheet implements IComparable /** * Set a cell value. * - * @param string $coordinate Coordinate of the cell, eg: 'A1' + * @param array|CellAddress|string $coordinate Coordinate of the cell as a string, eg: 'C5'; + * or as an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object. * @param mixed $value Value of the cell * @param string $dataType Explicit data type, see DataType::TYPE_* * @@ -1145,8 +1179,9 @@ class Worksheet implements IComparable */ public function setCellValueExplicit($coordinate, $value, $dataType) { - // Set value - $this->getCell($coordinate)->setValueExplicit($value, $dataType); + /** @var string $cellAddress */ + $cellAddress = $this->validateCellAddress($coordinate); + $this->getCell($cellAddress)->setValueExplicit($value, $dataType); return $this; } @@ -1154,6 +1189,10 @@ class Worksheet implements IComparable /** * Set a cell value by using numeric cell coordinates. * + * @Deprecated 1.23.0 + * Use the setCellValueExplicit() method with a cell address such as 'C5' instead;, + * or passing in an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object. + * * @param int $columnIndex Numeric column coordinate of the cell * @param int $row Numeric row coordinate of the cell * @param mixed $value Value of the cell @@ -1163,7 +1202,7 @@ class Worksheet implements IComparable */ public function setCellValueExplicitByColumnAndRow($columnIndex, $row, $value, $dataType) { - $this->getCellByColumnAndRow($columnIndex, $row)->setValueExplicit($value, $dataType); + $this->getCell([$columnIndex, $row])->setValueExplicit($value, $dataType); return $this; } @@ -1171,22 +1210,26 @@ class Worksheet implements IComparable /** * Get cell at a specific coordinate. * - * @param string $coordinate Coordinate of the cell, eg: 'A1' + * @param array|CellAddress|string $coordinate Coordinate of the cell as a string, eg: 'C5'; + * or as an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object. * * @return Cell Cell that was found or created */ - public function getCell(string $coordinate): Cell + public function getCell($coordinate): Cell { + /** @var string $cellAddress */ + $cellAddress = $this->validateCellAddress($coordinate); + // Shortcut for increased performance for the vast majority of simple cases - if ($this->cellCollection->has($coordinate)) { + if ($this->cellCollection->has($cellAddress)) { /** @var Cell $cell */ - $cell = $this->cellCollection->get($coordinate); + $cell = $this->cellCollection->get($cellAddress); return $cell; } /** @var Worksheet $sheet */ - [$sheet, $finalCoordinate] = $this->getWorksheetAndCoordinate($coordinate); + [$sheet, $finalCoordinate] = $this->getWorksheetAndCoordinate($cellAddress); $cell = $sheet->cellCollection->get($finalCoordinate); return $cell ?? $sheet->createNewCell($finalCoordinate); @@ -1210,7 +1253,7 @@ class Worksheet implements IComparable $sheet = $this->parent->getSheetByName($worksheetReference[0]); $finalCoordinate = strtoupper($worksheetReference[1]); - if (!$sheet) { + if ($sheet === null) { throw new Exception('Sheet not found for name: ' . $worksheetReference[0]); } } elseif ( @@ -1221,7 +1264,7 @@ class Worksheet implements IComparable $namedRange = $this->validateNamedRange($coordinate, true); if ($namedRange !== null) { $sheet = $namedRange->getWorksheet(); - if (!$sheet) { + if ($sheet === null) { throw new Exception('Sheet not found for named range: ' . $namedRange->getName()); } @@ -1231,7 +1274,7 @@ class Worksheet implements IComparable } } - if (!$sheet || !$finalCoordinate) { + if ($sheet === null || $finalCoordinate === null) { $sheet = $this; $finalCoordinate = strtoupper($coordinate); } @@ -1265,6 +1308,10 @@ class Worksheet implements IComparable /** * Get cell at a specific coordinate by using numeric cell coordinates. * + * @Deprecated 1.23.0 + * Use the getCell() method with a cell address such as 'C5' instead;, + * or passing in an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object. + * * @param int $columnIndex Numeric column coordinate of the cell * @param int $row Numeric row coordinate of the cell * @@ -1272,18 +1319,7 @@ class Worksheet implements IComparable */ public function getCellByColumnAndRow($columnIndex, $row): Cell { - $columnLetter = Coordinate::stringFromColumnIndex($columnIndex); - $coordinate = $columnLetter . $row; - - if ($this->cellCollection->has($coordinate)) { - /** @var Cell $cell */ - $cell = $this->cellCollection->get($coordinate); - - return $cell; - } - - // Create new cell object, if required - return $this->createNewCell($coordinate); + return $this->getCell([$columnIndex, $row]); } /** @@ -1328,14 +1364,15 @@ class Worksheet implements IComparable /** * Does the cell at a specific coordinate exist? * - * @param string $coordinate Coordinate of the cell eg: 'A1' - * - * @return bool + * @param array|CellAddress|string $coordinate Coordinate of the cell as a string, eg: 'C5'; + * or as an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object. */ - public function cellExists($coordinate) + public function cellExists($coordinate): bool { + /** @var string $cellAddress */ + $cellAddress = $this->validateCellAddress($coordinate); /** @var Worksheet $sheet */ - [$sheet, $finalCoordinate] = $this->getWorksheetAndCoordinate($coordinate); + [$sheet, $finalCoordinate] = $this->getWorksheetAndCoordinate($cellAddress); return $sheet->cellCollection->has($finalCoordinate); } @@ -1343,14 +1380,16 @@ class Worksheet implements IComparable /** * Cell at a specific coordinate by using numeric cell coordinates exists? * + * @Deprecated 1.23.0 + * Use the cellExists() method with a cell address such as 'C5' instead;, + * or passing in an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object. + * * @param int $columnIndex Numeric column coordinate of the cell * @param int $row Numeric row coordinate of the cell - * - * @return bool */ - public function cellExistsByColumnAndRow($columnIndex, $row) + public function cellExistsByColumnAndRow($columnIndex, $row): bool { - return $this->cellExists(Coordinate::stringFromColumnIndex($columnIndex) . $row); + return $this->cellExists([$columnIndex, $row]); } /** @@ -1548,12 +1587,15 @@ class Worksheet implements IComparable public function getStyleByColumnAndRow($columnIndex1, $row1, $columnIndex2 = null, $row2 = null) { if ($columnIndex2 !== null && $row2 !== null) { - $cellRange = Coordinate::stringFromColumnIndex($columnIndex1) . $row1 . ':' . Coordinate::stringFromColumnIndex($columnIndex2) . $row2; + $cellRange = new CellRange( + CellAddress::fromColumnAndRow($columnIndex1, $row1), + CellAddress::fromColumnAndRow($columnIndex2, $row2) + ); return $this->getStyle($cellRange); } - return $this->getStyle(Coordinate::stringFromColumnIndex($columnIndex1) . $row1); + return $this->getStyle(CellAddress::fromColumnAndRow($columnIndex1, $row1)); } /** @@ -1640,26 +1682,26 @@ class Worksheet implements IComparable /** * Set break on a cell. * - * @param string $coordinate Cell coordinate (e.g. A1) + * @param array|CellAddress|string $coordinate Coordinate of the cell as a string, eg: 'C5'; + * or as an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object. * @param int $break Break type (type of Worksheet::BREAK_*) * * @return $this */ public function setBreak($coordinate, $break) { - // Uppercase coordinate - $coordinate = strtoupper($coordinate); + $cellAddress = $this->validateCellAddress($coordinate); - if ($coordinate != '') { - if ($break == self::BREAK_NONE) { - if (isset($this->breaks[$coordinate])) { - unset($this->breaks[$coordinate]); - } - } else { - $this->breaks[$coordinate] = $break; + if ($cellAddress === '') { + throw new Exception('No cell coordinate specified.'); + } + + if ($break === self::BREAK_NONE) { + if (isset($this->breaks[$cellAddress])) { + unset($this->breaks[$cellAddress]); } } else { - throw new Exception('No cell coordinate specified.'); + $this->breaks[$cellAddress] = $break; } return $this; @@ -1668,6 +1710,10 @@ class Worksheet implements IComparable /** * Set break on a cell by using numeric cell coordinates. * + * @Deprecated 1.23.0 + * Use the setBreak() method with a cell address such as 'C5' instead;, + * or passing in an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object. + * * @param int $columnIndex Numeric column coordinate of the cell * @param int $row Numeric row coordinate of the cell * @param int $break Break type (type of Worksheet::BREAK_*) @@ -1676,7 +1722,7 @@ class Worksheet implements IComparable */ public function setBreakByColumnAndRow($columnIndex, $row, $break) { - return $this->setBreak(Coordinate::stringFromColumnIndex($columnIndex) . $row, $break); + return $this->setBreak([$columnIndex, $row], $break); } /** @@ -1779,6 +1825,11 @@ class Worksheet implements IComparable /** * Set merge on a cell range by using numeric cell coordinates. * + * @Deprecated 1.23.0 + * Use the mergeCells() method with a cell address range such as 'C5:E8' instead;, + * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5]), + * or a CellAddress object. + * * @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 last cell @@ -1788,7 +1839,10 @@ class Worksheet implements IComparable */ public function mergeCellsByColumnAndRow($columnIndex1, $row1, $columnIndex2, $row2) { - $cellRange = Coordinate::stringFromColumnIndex($columnIndex1) . $row1 . ':' . Coordinate::stringFromColumnIndex($columnIndex2) . $row2; + $cellRange = new CellRange( + CellAddress::fromColumnAndRow($columnIndex1, $row1), + CellAddress::fromColumnAndRow($columnIndex2, $row2) + ); return $this->mergeCells($cellRange); } @@ -1830,7 +1884,10 @@ class Worksheet implements IComparable */ public function unmergeCellsByColumnAndRow($columnIndex1, $row1, $columnIndex2, $row2) { - $cellRange = Coordinate::stringFromColumnIndex($columnIndex1) . $row1 . ':' . Coordinate::stringFromColumnIndex($columnIndex2) . $row2; + $cellRange = new CellRange( + CellAddress::fromColumnAndRow($columnIndex1, $row1), + CellAddress::fromColumnAndRow($columnIndex2, $row2) + ); return $this->unmergeCells($cellRange); } @@ -1896,7 +1953,10 @@ class Worksheet implements IComparable */ public function protectCellsByColumnAndRow($columnIndex1, $row1, $columnIndex2, $row2, $password, $alreadyHashed = false) { - $cellRange = Coordinate::stringFromColumnIndex($columnIndex1) . $row1 . ':' . Coordinate::stringFromColumnIndex($columnIndex2) . $row2; + $cellRange = new CellRange( + CellAddress::fromColumnAndRow($columnIndex1, $row1), + CellAddress::fromColumnAndRow($columnIndex2, $row2) + ); return $this->protectCells($cellRange, $password, $alreadyHashed); } @@ -1934,7 +1994,10 @@ class Worksheet implements IComparable */ public function unprotectCellsByColumnAndRow($columnIndex1, $row1, $columnIndex2, $row2) { - $cellRange = Coordinate::stringFromColumnIndex($columnIndex1) . $row1 . ':' . Coordinate::stringFromColumnIndex($columnIndex2) . $row2; + $cellRange = new CellRange( + CellAddress::fromColumnAndRow($columnIndex1, $row1), + CellAddress::fromColumnAndRow($columnIndex2, $row2) + ); return $this->unprotectCells($cellRange); } @@ -2026,23 +2089,26 @@ class Worksheet implements IComparable * - B1 will freeze the columns to the left of cell B1 (i.e column A) * - B2 will freeze the rows above and to the left of cell B2 (i.e row 1 and column A) * - * @param null|string $cell Position of the split + * @param null|array|CellAddress|string $coordinate Coordinate of the cell as a string, eg: 'C5'; + * or as an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object. + * Passing a null value for this argument will clear an existing freeze pane for this worksheet. * @param null|string $topLeftCell default position of the right bottom pane * * @return $this */ - public function freezePane($cell, $topLeftCell = null) + public function freezePane($coordinate, $topLeftCell = null) { - if (is_string($cell) && Coordinate::coordinateIsRange($cell)) { + $cellAddress = $this->validateCellAddress($coordinate, true); + if (is_string($cellAddress) && Coordinate::coordinateIsRange($cellAddress)) { throw new Exception('Freeze pane can not be set on a range of cells.'); } - if ($cell !== null && $topLeftCell === null) { - $coordinate = Coordinate::coordinateFromString($cell); + if ($cellAddress !== null && $topLeftCell === null) { + $coordinate = Coordinate::coordinateFromString($cellAddress); $topLeftCell = $coordinate[0] . $coordinate[1]; } - $this->freezePane = $cell; + $this->freezePane = $cellAddress; $this->topLeftCell = $topLeftCell; return $this; @@ -2058,6 +2124,10 @@ class Worksheet implements IComparable /** * Freeze Pane by using numeric cell coordinates. * + * @Deprecated 1.23.0 + * Use the freezePane() method with a cell address such as 'C5' instead;, + * or passing in an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object. + * * @param int $columnIndex Numeric column coordinate of the cell * @param int $row Numeric row coordinate of the cell * @@ -2065,7 +2135,7 @@ class Worksheet implements IComparable */ public function freezePaneByColumnAndRow($columnIndex, $row) { - return $this->freezePane(Coordinate::stringFromColumnIndex($columnIndex) . $row); + return $this->freezePane([$columnIndex, $row]); } /** @@ -2427,31 +2497,32 @@ class Worksheet implements IComparable /** * Get comment for cell. * - * @param string $cellCoordinate Cell coordinate to get comment for, eg: 'A1' + * @param array|CellAddress|string $cellCoordinate Coordinate of the cell as a string, eg: 'C5'; + * or as an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object. * * @return Comment */ public function getComment($cellCoordinate) { - // Uppercase coordinate - $cellCoordinate = strtoupper($cellCoordinate); + /** @var string $cellAddress */ + $cellAddress = $this->validateCellAddress($cellCoordinate); - if (Coordinate::coordinateIsRange($cellCoordinate)) { + if (Coordinate::coordinateIsRange($cellAddress)) { throw new Exception('Cell coordinate string can not be a range of cells.'); - } elseif (strpos($cellCoordinate, '$') !== false) { + } elseif (strpos($cellAddress, '$') !== false) { throw new Exception('Cell coordinate string must not be absolute.'); - } elseif ($cellCoordinate == '') { + } elseif ($cellAddress == '') { throw new Exception('Cell coordinate can not be zero-length string.'); } // Check if we already have a comment for this cell. - if (isset($this->comments[$cellCoordinate])) { - return $this->comments[$cellCoordinate]; + if (isset($this->comments[$cellAddress])) { + return $this->comments[$cellAddress]; } // If not, create a new comment. $newComment = new Comment(); - $this->comments[$cellCoordinate] = $newComment; + $this->comments[$cellAddress] = $newComment; return $newComment; } @@ -2459,6 +2530,10 @@ class Worksheet implements IComparable /** * Get comment for cell by using numeric cell coordinates. * + * @Deprecated 1.23.0 + * Use the getComment() method with a cell address such as 'C5' instead;, + * or passing in an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object. + * * @param int $columnIndex Numeric column coordinate of the cell * @param int $row Numeric row coordinate of the cell * @@ -2466,7 +2541,7 @@ class Worksheet implements IComparable */ public function getCommentByColumnAndRow($columnIndex, $row) { - return $this->getComment(Coordinate::stringFromColumnIndex($columnIndex) . $row); + return $this->getComment([$columnIndex, $row]); } /** @@ -2574,6 +2649,10 @@ class Worksheet implements IComparable /** * Selected cell by using numeric cell coordinates. * + * @Deprecated 1.23.0 + * Use the setSelectedCells() method with a cell address such as 'C5' instead;, + * or passing in an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object. + * * @param int $columnIndex Numeric column coordinate of the cell * @param int $row Numeric row coordinate of the cell * diff --git a/tests/PhpSpreadsheetTests/Cell/CellAddressTest.php b/tests/PhpSpreadsheetTests/Cell/CellAddressTest.php new file mode 100644 index 00000000..0057410a --- /dev/null +++ b/tests/PhpSpreadsheetTests/Cell/CellAddressTest.php @@ -0,0 +1,246 @@ +cellAddress()); + self::assertSame($expectedRowId, $cellAddressObject->rowId()); + self::assertSame($expectedColumnId, $cellAddressObject->columnId()); + self::assertSame($expectedColumnName, $cellAddressObject->columnName()); + } + + public function providerCreateFromCellAddress(): array + { + return [ + ['A1', 'A', 1, 1], + ['C5', 'C', 3, 5], + ['IV256', 'IV', 256, 256], + ]; + } + + /** + * @dataProvider providerCreateFromCellAddressException + * + * @param mixed $cellAddress + */ + public function testCreateFromCellAddressException($cellAddress): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage( + $cellAddress === '' + ? 'Cell coordinate can not be zero-length string' + : "Invalid cell coordinate {$cellAddress}" + ); + + CellAddress::fromCellAddress($cellAddress); + } + + public function providerCreateFromCellAddressException(): array + { + return [ + ['INVALID'], + [''], + ['IV'], + ['12'], + [123], + ]; + } + + /** + * @dataProvider providerCreateFromColumnAndRow + */ + public function testCreateFromColumnAndRow( + int $columnId, + int $rowId, + string $expectedCellAddress, + string $expectedColumnName + ): void { + $cellAddressObject = CellAddress::fromColumnAndRow($columnId, $rowId); + + self::assertSame($expectedCellAddress, (string) $cellAddressObject); + self::assertSame($expectedCellAddress, $cellAddressObject->cellAddress()); + self::assertSame($rowId, $cellAddressObject->rowId()); + self::assertSame($columnId, $cellAddressObject->columnId()); + self::assertSame($expectedColumnName, $cellAddressObject->columnName()); + } + + /** + * @dataProvider providerCreateFromColumnRowException + * + * @param mixed $columnId + * @param mixed $rowId + */ + public function testCreateFromColumnRowException($columnId, $rowId): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Row and Column Ids must be positive integer values'); + + CellAddress::fromColumnAndRow($columnId, $rowId); + } + + public function providerCreateFromColumnAndRow(): array + { + return [ + [1, 1, 'A1', 'A'], + [3, 5, 'C5', 'C'], + [256, 256, 'IV256', 'IV'], + ]; + } + + /** + * @dataProvider providerCreateFromColumnRowArray + */ + public function testCreateFromColumnRowArray( + int $columnId, + int $rowId, + string $expectedCellAddress, + string $expectedColumnName + ): void { + $columnRowArray = [$columnId, $rowId]; + $cellAddressObject = CellAddress::fromColumnRowArray($columnRowArray); + + self::assertSame($expectedCellAddress, (string) $cellAddressObject); + self::assertSame($expectedCellAddress, $cellAddressObject->cellAddress()); + self::assertSame($rowId, $cellAddressObject->rowId()); + self::assertSame($columnId, $cellAddressObject->columnId()); + self::assertSame($expectedColumnName, $cellAddressObject->columnName()); + } + + public function providerCreateFromColumnRowArray(): array + { + return [ + [1, 1, 'A1', 'A'], + [3, 5, 'C5', 'C'], + [256, 256, 'IV256', 'IV'], + ]; + } + + /** + * @dataProvider providerCreateFromColumnRowException + * + * @param mixed $columnId + * @param mixed $rowId + */ + public function testCreateFromColumnRowArrayException($columnId, $rowId): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Row and Column Ids must be positive integer values'); + + $columnRowArray = [$columnId, $rowId]; + CellAddress::fromColumnRowArray($columnRowArray); + } + + public function providerCreateFromColumnRowException(): array + { + return [ + [-1, 1], + [3, 'A'], + ]; + } + + /** + * @dataProvider providerCreateFromCellAddressWithWorksheet + */ + public function testCreateFromCellAddressWithWorksheet( + string $cellAddress, + string $expectedCellAddress, + string $expectedColumnName, + int $expectedColumnId, + int $expectedRowId + ): void { + $spreadsheet = new Spreadsheet(); + $worksheet = $spreadsheet->getActiveSheet(); + $worksheet->setTitle("Mark's Worksheet"); + + $cellAddressObject = CellAddress::fromCellAddress($cellAddress, $worksheet); + + self::assertSame($expectedCellAddress, (string) $cellAddressObject); + self::assertSame($cellAddress, $cellAddressObject->cellAddress()); + self::assertSame($expectedRowId, $cellAddressObject->rowId()); + self::assertSame($expectedColumnId, $cellAddressObject->columnId()); + self::assertSame($expectedColumnName, $cellAddressObject->columnName()); + } + + public function providerCreateFromCellAddressWithWorksheet(): array + { + return [ + ['A1', "'Mark''s Worksheet'!A1", 'A', 1, 1], + ['C5', "'Mark''s Worksheet'!C5", 'C', 3, 5], + ['IV256', "'Mark''s Worksheet'!IV256", 'IV', 256, 256], + ]; + } + + public function testNextRow(): void + { + $cellAddress = CellAddress::fromCellAddress('C5'); + // default single row + $cellAddressC6 = $cellAddress->nextRow(); + self::assertSame('C6', (string) $cellAddressC6); + // multiple rows + $cellAddressC9 = $cellAddress->nextRow(4); + self::assertSame('C9', (string) $cellAddressC9); + // negative rows + $cellAddressC3 = $cellAddress->nextRow(-2); + self::assertSame('C3', (string) $cellAddressC3); + // negative beyond the minimum + $cellAddressC1 = $cellAddress->nextRow(-10); + self::assertSame('C1', (string) $cellAddressC1); + + // Check that the original object is still unchanged + self::assertSame('C5', (string) $cellAddress); + } + + public function testPreviousRow(): void + { + $cellAddress = CellAddress::fromCellAddress('C5'); + // default single row + $cellAddressC4 = $cellAddress->previousRow(); + self::assertSame('C4', (string) $cellAddressC4); + } + + public function testNextColumn(): void + { + $cellAddress = CellAddress::fromCellAddress('C5'); + // default single row + $cellAddressD5 = $cellAddress->nextColumn(); + self::assertSame('D5', (string) $cellAddressD5); + // multiple rows + $cellAddressG5 = $cellAddress->nextColumn(4); + self::assertSame('G5', (string) $cellAddressG5); + // negative rows + $cellAddressB5 = $cellAddress->nextColumn(-1); + self::assertSame('B5', (string) $cellAddressB5); + // negative beyond the minimum + $cellAddressA5 = $cellAddress->nextColumn(-10); + self::assertSame('A5', (string) $cellAddressA5); + + // Check that the original object is still unchanged + self::assertSame('C5', (string) $cellAddress); + } + + public function testPreviousColumn(): void + { + $cellAddress = CellAddress::fromCellAddress('C5'); + // default single row + $cellAddressC4 = $cellAddress->previousColumn(); + self::assertSame('B5', (string) $cellAddressC4); + } +} diff --git a/tests/PhpSpreadsheetTests/Cell/CellRangeTest.php b/tests/PhpSpreadsheetTests/Cell/CellRangeTest.php new file mode 100644 index 00000000..856f0e50 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Cell/CellRangeTest.php @@ -0,0 +1,113 @@ +getActiveSheet(); + $worksheet->setTitle("Mark's Worksheet"); + + $from = CellAddress::fromCellAddress('B5', $worksheet); + $to = CellAddress::fromCellAddress('E2'); + $cellRange = new CellRange($from, $to); + self::assertSame("'Mark''s Worksheet'!B2:E5", (string) $cellRange); + } + + public function testCreateCellRangeWithWorksheets(): void + { + $spreadsheet = new Spreadsheet(); + $worksheet = $spreadsheet->getActiveSheet(); + $worksheet->setTitle("Mark's Worksheet"); + + $from = CellAddress::fromCellAddress('B5', $worksheet); + $to = CellAddress::fromCellAddress('E2', $worksheet); + $cellRange = new CellRange($from, $to); + self::assertSame("'Mark''s Worksheet'!B2:E5", (string) $cellRange); + } + + public function testSingleCellRange(): void + { + $from = CellAddress::fromCellAddress('C3'); + $to = CellAddress::fromCellAddress('C3'); + $cellRange = new CellRange($from, $to); + self::assertSame('C3', (string) $cellRange); + } + + public function testSingleCellRangeWithWorksheet(): void + { + $spreadsheet = new Spreadsheet(); + $worksheet = $spreadsheet->getActiveSheet(); + $worksheet->setTitle("Mark's Worksheet"); + + $from = CellAddress::fromCellAddress('C3', $worksheet); + $to = CellAddress::fromCellAddress('C3'); + $cellRange = new CellRange($from, $to); + self::assertSame("'Mark''s Worksheet'!C3", (string) $cellRange); + } + + public function testRangeFrom(): void + { + $from = CellAddress::fromCellAddress('B5'); + $to = CellAddress::fromCellAddress('E2'); + $cellRange = new CellRange($from, $to); + self::assertSame('B2', (string) $cellRange->from()); + } + + public function testRangeTo(): void + { + $from = CellAddress::fromCellAddress('B5'); + $to = CellAddress::fromCellAddress('E2'); + $cellRange = new CellRange($from, $to); + self::assertSame('E5', (string) $cellRange->to()); + } + + public function testCreateCellRangeWithMismatchedWorksheets(): void + { + $spreadsheet = new Spreadsheet(); + $worksheet = $spreadsheet->getActiveSheet(); + $worksheet->setTitle("Mark's Worksheet"); + $secondWorksheet = new Worksheet($spreadsheet, 'A Second Worksheet'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('3d Cell Ranges are not supported'); + + $from = CellAddress::fromCellAddress('B5', $worksheet); + $to = CellAddress::fromCellAddress('E2', $secondWorksheet); + new CellRange($from, $to); + } + + public function testCreateCellRangeWithMismatchedSpreadsheets(): void + { + $spreadsheet1 = new Spreadsheet(); + $worksheet1 = $spreadsheet1->getActiveSheet(); + $worksheet1->setTitle("Mark's Worksheet"); + $spreadsheet2 = new Spreadsheet(); + $worksheet2 = $spreadsheet2->getActiveSheet(); + $worksheet2->setTitle("Mark's Worksheet"); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Worksheets must be in the same spreadsheet'); + + $from = CellAddress::fromCellAddress('B5', $worksheet1); + $to = CellAddress::fromCellAddress('E2', $worksheet2); + new CellRange($from, $to); + } +} diff --git a/tests/PhpSpreadsheetTests/Cell/ColumnRangeTest.php b/tests/PhpSpreadsheetTests/Cell/ColumnRangeTest.php new file mode 100644 index 00000000..6fa18d89 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Cell/ColumnRangeTest.php @@ -0,0 +1,65 @@ +from()); + self::assertSame('E', $columnRange->to()); + self::assertSame(3, $columnRange->fromIndex()); + self::assertSame(5, $columnRange->toIndex()); + self::assertSame('C:E', (string) $columnRange); + self::assertSame(3, $columnRange->columnCount()); + self::assertSame('C1:E1048576', (string) $columnRange->toCellRange()); + } + + public function testCreateSingleColumnRange(): void + { + $columnRange = new ColumnRange('E'); + self::assertSame('E', $columnRange->from()); + self::assertSame('E', $columnRange->to()); + self::assertSame('E:E', (string) $columnRange); + self::assertSame(1, $columnRange->columnCount()); + self::assertSame('E1:E1048576', (string) $columnRange->toCellRange()); + } + + public function testCreateColumnRangeWithWorksheet(): void + { + $spreadsheet = new Spreadsheet(); + $worksheet = $spreadsheet->getActiveSheet(); + $worksheet->setTitle("Mark's Worksheet"); + + $columnRange = new ColumnRange('C', 'E', $worksheet); + self::assertSame('C', $columnRange->from()); + self::assertSame('E', $columnRange->to()); + self::assertSame("'Mark''s Worksheet'!C:E", (string) $columnRange); + self::assertSame("'Mark''s Worksheet'!C1:E1048576", (string) $columnRange->toCellRange()); + } + + public function testCreateColumnRangeFromArray(): void + { + $columnRange = ColumnRange::fromArray(['C', 'E']); + self::assertSame('C', $columnRange->from()); + self::assertSame('E', $columnRange->to()); + self::assertSame('C:E', (string) $columnRange); + self::assertSame(3, $columnRange->columnCount()); + self::assertSame('C1:E1048576', (string) $columnRange->toCellRange()); + } + + public function testCreateColumnRangeFromIndexes(): void + { + $columnRange = ColumnRange::fromColumnIndexes(3, 5); + self::assertSame('C', $columnRange->from()); + self::assertSame('E', $columnRange->to()); + self::assertSame('C:E', (string) $columnRange); + self::assertSame(3, $columnRange->columnCount()); + self::assertSame('C1:E1048576', (string) $columnRange->toCellRange()); + } +} diff --git a/tests/PhpSpreadsheetTests/Cell/RowRangeTest.php b/tests/PhpSpreadsheetTests/Cell/RowRangeTest.php new file mode 100644 index 00000000..41d4992c --- /dev/null +++ b/tests/PhpSpreadsheetTests/Cell/RowRangeTest.php @@ -0,0 +1,51 @@ +from()); + self::assertSame(5, $rowRange->to()); + self::assertSame('3:5', (string) $rowRange); + self::assertSame(3, $rowRange->rowCount()); + self::assertSame('A3:XFD5', (string) $rowRange->toCellRange()); + } + + public function testCreateSingleRowRange(): void + { + $rowRange = new RowRange(3); + self::assertSame(3, $rowRange->from()); + self::assertSame(3, $rowRange->to()); + self::assertSame('3:3', (string) $rowRange); + self::assertSame(1, $rowRange->rowCount()); + } + + public function testCreateRowRangeWithWorksheet(): void + { + $spreadsheet = new Spreadsheet(); + $worksheet = $spreadsheet->getActiveSheet(); + $worksheet->setTitle("Mark's Worksheet"); + + $rowRange = new RowRange(3, 5, $worksheet); + self::assertSame(3, $rowRange->from()); + self::assertSame(5, $rowRange->to()); + self::assertSame("'Mark''s Worksheet'!3:5", (string) $rowRange); + } + + public function testCreateRowRangeFromArray(): void + { + $rowRange = RowRange::fromArray([3, 5]); + self::assertSame(3, $rowRange->from()); + self::assertSame(5, $rowRange->to()); + self::assertSame('3:5', (string) $rowRange); + self::assertSame(3, $rowRange->rowCount()); + self::assertSame('A3:XFD5', (string) $rowRange->toCellRange()); + } +} From 1849737abc77675e2ce66c51eadaad0db31ac88c Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Fri, 1 Apr 2022 11:26:47 +0200 Subject: [PATCH 03/52] Add functionality to shift RowRange and ColumnRange --- src/PhpSpreadsheet/Cell/CellAddress.php | 2 ++ src/PhpSpreadsheet/Cell/ColumnRange.php | 16 +++++++++++++ src/PhpSpreadsheet/Cell/RowRange.php | 16 +++++++++++++ .../Cell/ColumnRangeTest.php | 24 +++++++++++++++++++ .../PhpSpreadsheetTests/Cell/RowRangeTest.php | 24 +++++++++++++++++++ 5 files changed, 82 insertions(+) diff --git a/src/PhpSpreadsheet/Cell/CellAddress.php b/src/PhpSpreadsheet/Cell/CellAddress.php index 389f09ac..412cff56 100644 --- a/src/PhpSpreadsheet/Cell/CellAddress.php +++ b/src/PhpSpreadsheet/Cell/CellAddress.php @@ -66,6 +66,7 @@ class CellAddress { self::validateColumnAndRow($columnId, $rowId); + /** @phpstan-ignore-next-line */ return new static(Coordinate::stringFromColumnIndex($columnId) . ((string) $rowId), $worksheet); } @@ -81,6 +82,7 @@ class CellAddress */ public static function fromCellAddress($cellAddress, ?Worksheet $worksheet = null): self { + /** @phpstan-ignore-next-line */ return new static($cellAddress, $worksheet); } diff --git a/src/PhpSpreadsheet/Cell/ColumnRange.php b/src/PhpSpreadsheet/Cell/ColumnRange.php index d38caea8..dbd91bb6 100644 --- a/src/PhpSpreadsheet/Cell/ColumnRange.php +++ b/src/PhpSpreadsheet/Cell/ColumnRange.php @@ -67,6 +67,22 @@ class ColumnRange return $this->to - $this->from + 1; } + public function shiftDown(int $offset = 1): self + { + $newFrom = $this->from + $offset; + $newFrom = ($newFrom < 1) ? 1 : $newFrom; + + $newTo = $this->to + $offset; + $newTo = ($newTo < 1) ? 1 : $newTo; + + return self::fromColumnIndexes($newFrom, $newTo, $this->worksheet); + } + + public function shiftUp(int $offset = 1): self + { + return $this->shiftDown(0 - $offset); + } + public function from(): string { return Coordinate::stringFromColumnIndex($this->from); diff --git a/src/PhpSpreadsheet/Cell/RowRange.php b/src/PhpSpreadsheet/Cell/RowRange.php index 07190702..bef97111 100644 --- a/src/PhpSpreadsheet/Cell/RowRange.php +++ b/src/PhpSpreadsheet/Cell/RowRange.php @@ -58,6 +58,22 @@ class RowRange return $this->to - $this->from + 1; } + public function shiftRight(int $offset = 1): self + { + $newFrom = $this->from + $offset; + $newFrom = ($newFrom < 1) ? 1 : $newFrom; + + $newTo = $this->to + $offset; + $newTo = ($newTo < 1) ? 1 : $newTo; + + return new self($newFrom, $newTo, $this->worksheet); + } + + public function shiftLeft(int $offset = 1): self + { + return $this->shiftRight(0 - $offset); + } + public function toCellRange(): CellRange { return new CellRange( diff --git a/tests/PhpSpreadsheetTests/Cell/ColumnRangeTest.php b/tests/PhpSpreadsheetTests/Cell/ColumnRangeTest.php index 6fa18d89..14455701 100644 --- a/tests/PhpSpreadsheetTests/Cell/ColumnRangeTest.php +++ b/tests/PhpSpreadsheetTests/Cell/ColumnRangeTest.php @@ -62,4 +62,28 @@ class ColumnRangeTest extends TestCase self::assertSame(3, $columnRange->columnCount()); self::assertSame('C1:E1048576', (string) $columnRange->toCellRange()); } + + public function testColumnRangeNext(): void + { + $columnRange = new ColumnRange('C', 'E'); + $columnRangeNext = $columnRange->shiftDown(3); + + self::assertSame('F', $columnRangeNext->from()); + self::assertSame('H', $columnRangeNext->to()); + + // Check that original Column Range isn't changed + self::assertSame('C:E', (string) $columnRange); + } + + public function testColumnRangePrevious(): void + { + $columnRange = new ColumnRange('C', 'E'); + $columnRangeNext = $columnRange->shiftUp(); + + self::assertSame('B', $columnRangeNext->from()); + self::assertSame('D', $columnRangeNext->to()); + + // Check that original Column Range isn't changed + self::assertSame('C:E', (string) $columnRange); + } } diff --git a/tests/PhpSpreadsheetTests/Cell/RowRangeTest.php b/tests/PhpSpreadsheetTests/Cell/RowRangeTest.php index 41d4992c..32605cf0 100644 --- a/tests/PhpSpreadsheetTests/Cell/RowRangeTest.php +++ b/tests/PhpSpreadsheetTests/Cell/RowRangeTest.php @@ -48,4 +48,28 @@ class RowRangeTest extends TestCase self::assertSame(3, $rowRange->rowCount()); self::assertSame('A3:XFD5', (string) $rowRange->toCellRange()); } + + public function testRowRangeNext(): void + { + $rowRange = new RowRange(3, 5); + $rowRangeNext = $rowRange->shiftRight(3); + + self::assertSame(6, $rowRangeNext->from()); + self::assertSame(8, $rowRangeNext->to()); + + // Check that original Row Range isn't changed + self::assertSame('3:5', (string) $rowRange); + } + + public function testRowRangePrevious(): void + { + $rowRange = new RowRange(3, 5); + $rowRangeNext = $rowRange->shiftLeft(); + + self::assertSame(2, $rowRangeNext->from()); + self::assertSame(4, $rowRangeNext->to()); + + // Check that original Row Range isn't changed + self::assertSame('3:5', (string) $rowRange); + } } From 6b4ffda5ae3b394b5ac0bf8a9197ed25ffc35759 Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Fri, 1 Apr 2022 12:23:32 +0200 Subject: [PATCH 04/52] Add functionality to adjust CellRange by "modifying" the from/to CellAddress Value objects --- .php-cs-fixer.dist.php | 2 +- CHANGELOG.md | 3 +- src/PhpSpreadsheet/Cell/AddressRange.php | 22 +++++++ src/PhpSpreadsheet/Cell/CellAddress.php | 2 +- src/PhpSpreadsheet/Cell/CellRange.php | 64 ++++++++++++++++++- src/PhpSpreadsheet/Cell/ColumnRange.php | 6 +- src/PhpSpreadsheet/Cell/RowRange.php | 6 +- .../Cell/CellRangeTest.php | 40 ++++++++++++ 8 files changed, 131 insertions(+), 14 deletions(-) create mode 100644 src/PhpSpreadsheet/Cell/AddressRange.php diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 8a8886c2..ca2feb42 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -21,7 +21,7 @@ $config 'braces' => true, 'cast_spaces' => true, 'class_attributes_separation' => ['elements' => ['method' => 'one', 'property' => 'one']], // const are often grouped with other related const - 'class_definition' => true, + 'class_definition' => false, 'class_keyword_remove' => false, // ::class keyword gives us better support in IDE 'combine_consecutive_issets' => true, 'combine_consecutive_unsets' => true, diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d20a9cc..35ed928e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Added -- Implementation of the FILTER(), SORT(), SORTBY() and UNIQUE() Lookup/Reference (array) functions +- Introduced CellAddress, CellRange, RowRange and ColumnRange value objects that can be used as an alternative to a string value (e.g. `'C5'`, `'B2:D4'`, `'2:2'` or `'B:C'`) in appropriate contexts. +- Implementation of the FILTER(), SORT(), SORTBY() and UNIQUE() Lookup/Reference (array) functions. - Implementation of the ISREF() Information function. - Added support for reading "formatted" numeric values from Csv files; although default behaviour of reading these values as strings is preserved. diff --git a/src/PhpSpreadsheet/Cell/AddressRange.php b/src/PhpSpreadsheet/Cell/AddressRange.php new file mode 100644 index 00000000..54752317 --- /dev/null +++ b/src/PhpSpreadsheet/Cell/AddressRange.php @@ -0,0 +1,22 @@ +cellAddress = str_replace('$', '', $cellAddress); [$this->columnName, $rowId] = Coordinate::coordinateFromString($cellAddress); diff --git a/src/PhpSpreadsheet/Cell/CellRange.php b/src/PhpSpreadsheet/Cell/CellRange.php index 4880fa6c..908a0d07 100644 --- a/src/PhpSpreadsheet/Cell/CellRange.php +++ b/src/PhpSpreadsheet/Cell/CellRange.php @@ -5,7 +5,7 @@ namespace PhpOffice\PhpSpreadsheet\Cell; use PhpOffice\PhpSpreadsheet\Exception; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; -class CellRange +class CellRange implements AddressRange { /** * @var CellAddress @@ -34,8 +34,8 @@ class CellRange $toWorksheet = $to->worksheet(); $this->validateWorksheets($fromWorksheet, $toWorksheet); - $this->from = CellAddress::fromColumnAndRow($firstColumn, $firstRow, $fromWorksheet); - $this->to = CellAddress::fromColumnAndRow($lastColumn, $lastRow, $toWorksheet); + $this->from = $this->cellAddressWrapper($firstColumn, $firstRow, $fromWorksheet); + $this->to = $this->cellAddressWrapper($lastColumn, $lastRow, $toWorksheet); } private function validateWorksheets(?Worksheet $fromWorksheet, ?Worksheet $toWorksheet): void @@ -54,18 +54,76 @@ class CellRange } } + private function cellAddressWrapper(int $column, int $row, ?Worksheet $worksheet = null): CellAddress + { + $cellAddress = Coordinate::stringFromColumnIndex($column) . (string) $row; + + return new class ($cellAddress, $worksheet) extends CellAddress { + public function nextRow(int $offset = 1): CellAddress + { + /** @var CellAddress $result */ + $result = parent::nextRow($offset); + $this->rowId = $result->rowId; + $this->cellAddress = $result->cellAddress; + + return $this; + } + + public function previousRow(int $offset = 1): CellAddress + { + /** @var CellAddress $result */ + $result = parent::previousRow($offset); + $this->rowId = $result->rowId; + $this->cellAddress = $result->cellAddress; + + return $this; + } + + public function nextColumn(int $offset = 1): CellAddress + { + /** @var CellAddress $result */ + $result = parent::nextColumn($offset); + $this->columnId = $result->columnId; + $this->columnName = $result->columnName; + $this->cellAddress = $result->cellAddress; + + return $this; + } + + public function previousColumn(int $offset = 1): CellAddress + { + /** @var CellAddress $result */ + $result = parent::previousColumn($offset); + $this->columnId = $result->columnId; + $this->columnName = $result->columnName; + $this->cellAddress = $result->cellAddress; + + return $this; + } + }; + } + public function from(): CellAddress { + // Re-order from/to in case the cell addresses have been modified + $this->validateFromTo($this->from, $this->to); + return $this->from; } public function to(): CellAddress { + // Re-order from/to in case the cell addresses have been modified + $this->validateFromTo($this->from, $this->to); + return $this->to; } public function __toString(): string { + // Re-order from/to in case the cell addresses have been modified + $this->validateFromTo($this->from, $this->to); + if ($this->from->cellAddress() === $this->to->cellAddress()) { return "{$this->from->fullCellAddress()}"; } diff --git a/src/PhpSpreadsheet/Cell/ColumnRange.php b/src/PhpSpreadsheet/Cell/ColumnRange.php index dbd91bb6..1e521a13 100644 --- a/src/PhpSpreadsheet/Cell/ColumnRange.php +++ b/src/PhpSpreadsheet/Cell/ColumnRange.php @@ -4,10 +4,8 @@ namespace PhpOffice\PhpSpreadsheet\Cell; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; -class ColumnRange +class ColumnRange implements AddressRange { - private const MAX_ROW = 1048576; - /** * @var ?Worksheet */ @@ -107,7 +105,7 @@ class ColumnRange { return new CellRange( CellAddress::fromColumnAndRow($this->from, 1, $this->worksheet), - CellAddress::fromColumnAndRow($this->to, self::MAX_ROW) + CellAddress::fromColumnAndRow($this->to, AddressRange::MAX_ROW) ); } diff --git a/src/PhpSpreadsheet/Cell/RowRange.php b/src/PhpSpreadsheet/Cell/RowRange.php index bef97111..38e6c141 100644 --- a/src/PhpSpreadsheet/Cell/RowRange.php +++ b/src/PhpSpreadsheet/Cell/RowRange.php @@ -4,10 +4,8 @@ namespace PhpOffice\PhpSpreadsheet\Cell; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; -class RowRange +class RowRange implements AddressRange { - private const MAX_COLUMN = 'XFD'; - /** * @var ?Worksheet */ @@ -78,7 +76,7 @@ class RowRange { return new CellRange( CellAddress::fromColumnAndRow(Coordinate::columnIndexFromString('A'), $this->from, $this->worksheet), - CellAddress::fromColumnAndRow(Coordinate::columnIndexFromString(self::MAX_COLUMN), $this->to) + CellAddress::fromColumnAndRow(Coordinate::columnIndexFromString(AddressRange::MAX_COLUMN), $this->to) ); } diff --git a/tests/PhpSpreadsheetTests/Cell/CellRangeTest.php b/tests/PhpSpreadsheetTests/Cell/CellRangeTest.php index 856f0e50..1557a698 100644 --- a/tests/PhpSpreadsheetTests/Cell/CellRangeTest.php +++ b/tests/PhpSpreadsheetTests/Cell/CellRangeTest.php @@ -110,4 +110,44 @@ class CellRangeTest extends TestCase $to = CellAddress::fromCellAddress('E2', $worksheet2); new CellRange($from, $to); } + + public function testShiftRangeTo(): void + { + $from = CellAddress::fromCellAddress('B5'); + $to = CellAddress::fromCellAddress('E2'); + $cellRange = new CellRange($from, $to); + self::assertSame('B2:E5', (string) $cellRange); + + $cellRange->to() + ->nextColumn(2) + ->nextRow(2); + + self::assertSame('B2', (string) $cellRange->from()); + self::assertSame('G7', (string) $cellRange->to()); + self::assertSame('B2:G7', (string) $cellRange); + + $cellRange->to() + ->previousColumn() + ->previousRow(); + + self::assertSame('B2', (string) $cellRange->from()); + self::assertSame('F6', (string) $cellRange->to()); + self::assertSame('B2:F6', (string) $cellRange); + } + + public function testShiftRangeFrom(): void + { + $from = CellAddress::fromCellAddress('B5'); + $to = CellAddress::fromCellAddress('E2'); + $cellRange = new CellRange($from, $to); + self::assertSame('B2:E5', (string) $cellRange); + + $cellRange->from() + ->nextColumn(5) + ->nextRow(5); + + self::assertSame('E5', (string) $cellRange->from()); + self::assertSame('G7', (string) $cellRange->to()); + self::assertSame('E5:G7', (string) $cellRange); + } } From 3ae5a3fae397973c5d81c1250a8fd2d5896653eb Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Fri, 1 Apr 2022 16:55:58 +0200 Subject: [PATCH 05/52] Additional unit tests for BColumnAndRow methods, to verify that they still work as expected --- phpstan-baseline.neon | 5 - src/PhpSpreadsheet/Worksheet/Worksheet.php | 211 ++++++++++++------ .../Worksheet/ByColumnAndRowTest.php | 188 ++++++++++++++++ 3 files changed, 335 insertions(+), 69 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Worksheet/ByColumnAndRowTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 9ecc0f01..278efd69 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -4345,11 +4345,6 @@ parameters: count: 1 path: src/PhpSpreadsheet/Worksheet/Worksheet.php - - - message: "#^Result of && is always true\\.$#" - count: 1 - path: src/PhpSpreadsheet/Worksheet/Worksheet.php - - message: "#^Right side of && is always true\\.$#" count: 1 diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index 54fcc0de..2f8f6972 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -4,6 +4,8 @@ namespace PhpOffice\PhpSpreadsheet\Worksheet; use ArrayObject; use PhpOffice\PhpSpreadsheet\Calculation\Calculation; +use PhpOffice\PhpSpreadsheet\Calculation\Functions; +use PhpOffice\PhpSpreadsheet\Cell\AddressRange; use PhpOffice\PhpSpreadsheet\Cell\Cell; use PhpOffice\PhpSpreadsheet\Cell\CellAddress; use PhpOffice\PhpSpreadsheet\Cell\CellRange; @@ -1118,6 +1120,9 @@ class Worksheet implements IComparable return null; } [$worksheet, $address] = self::extractSheetTitle($cellAddress, true); +// if (!empty($worksheet) && $worksheet !== $this->getTitle()) { +// throw new Exception('Reference is not for this worksheet'); +// } return empty($worksheet) ? strtoupper($address) : $worksheet . '!' . strtoupper($address); } @@ -1129,6 +1134,50 @@ class Worksheet implements IComparable return (string) $cellAddress; } + /** + * Validate a cell range. + * + * @param AddressRange|array|CellAddress|string $cellRange Coordinate of the cells as a string, eg: 'C5:F12'; + * or as an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 12]), + * or as a CellAddress or AddressRange object. + */ + protected function validateCellOrCellRange($cellRange): string + { + if (is_object($cellRange) && $cellRange instanceof CellAddress) { + $cellRange = new CellRange($cellRange, $cellRange); + } + + return $this->validateCellRange($cellRange); + } + + /** + * Validate a cell range. + * + * @param AddressRange|array|string $cellRange Coordinate of the cells as a string, eg: 'C5:F12'; + * or as an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 12]), + * or as an AddressRange object. + */ + protected function validateCellRange($cellRange): string + { + if (is_string($cellRange)) { + [$worksheet, $addressRange] = self::extractSheetTitle($cellRange, true); + + // Convert 'A:C' to 'A1:C1048576' + $addressRange = self::pregReplace('/^([A-Z]+):([A-Z]+)$/', '${1}1:${2}1048576', $addressRange); + // Convert '1:3' to 'A1:XFD3' + $addressRange = self::pregReplace('/^(\\d+):(\\d+)$/', 'A${1}:XFD${2}', $addressRange); + + return empty($worksheet) ? strtoupper($addressRange) : $worksheet . '!' . strtoupper($addressRange); + } + + if (is_array($cellRange)) { + [$from, $to] = array_chunk($cellRange, 2); + $cellRange = new CellRange(CellAddress::fromColumnRowArray($from), CellAddress::fromColumnRowArray($to)); + } + + return (string) $cellRange; + } + /** * Set a cell value. * @@ -1455,10 +1504,15 @@ class Worksheet implements IComparable /** * Get style for cell. * - * @param string $cellCoordinate Cell coordinate (or range) to get style for, eg: 'A1' + * @param AddressRange|array|CellAddress|string $cellCoordinate + * A simple string containing a cell address like 'A1' or a cell range like 'A1:E10' + * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), + * or a CellAddress or AddressRange object. */ public function getStyle($cellCoordinate): Style { + $cellCoordinate = $this->validateCellRange($cellCoordinate); + // set this sheet as active $this->parent->setActiveSheetIndex($this->parent->getIndex($this)); @@ -1468,6 +1522,35 @@ class Worksheet implements IComparable return $this->parent->getCellXfSupervisor(); } + /** + * Get style for cell by using numeric cell coordinates. + * + * @Deprecated 1.23.0 + * Use the getStyle() method with a cell address range such as 'C5:F8' instead;, + * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), + * or an AddressRange object. + * + * @param int $columnIndex1 Numeric column coordinate of the cell + * @param int $row1 Numeric row coordinate of the cell + * @param null|int $columnIndex2 Numeric column coordinate of the range cell + * @param null|int $row2 Numeric row coordinate of the range cell + * + * @return Style + */ + public function getStyleByColumnAndRow($columnIndex1, $row1, $columnIndex2 = null, $row2 = null) + { + if ($columnIndex2 !== null && $row2 !== null) { + $cellRange = new CellRange( + CellAddress::fromColumnAndRow($columnIndex1, $row1), + CellAddress::fromColumnAndRow($columnIndex2, $row2) + ); + + return $this->getStyle($cellRange); + } + + return $this->getStyle(CellAddress::fromColumnAndRow($columnIndex1, $row1)); + } + /** * Get conditional styles for a cell. * @@ -1522,7 +1605,7 @@ class Worksheet implements IComparable { $coordinate = strtoupper($coordinate); if (strpos($coordinate, ':') !== false) { - return isset($this->conditionalStylesCollection[strtoupper($coordinate)]); + return isset($this->conditionalStylesCollection[$coordinate]); } $cell = $this->getCell($coordinate); @@ -1574,30 +1657,6 @@ class Worksheet implements IComparable return $this; } - /** - * Get style for cell by using numeric cell coordinates. - * - * @param int $columnIndex1 Numeric column coordinate of the cell - * @param int $row1 Numeric row coordinate of the cell - * @param null|int $columnIndex2 Numeric column coordinate of the range cell - * @param null|int $row2 Numeric row coordinate of the range cell - * - * @return Style - */ - public function getStyleByColumnAndRow($columnIndex1, $row1, $columnIndex2 = null, $row2 = null) - { - if ($columnIndex2 !== null && $row2 !== null) { - $cellRange = new CellRange( - CellAddress::fromColumnAndRow($columnIndex1, $row1), - CellAddress::fromColumnAndRow($columnIndex2, $row2) - ); - - return $this->getStyle($cellRange); - } - - return $this->getStyle(CellAddress::fromColumnAndRow($columnIndex1, $row1)); - } - /** * Duplicate cell style to a range of cells. * @@ -1690,11 +1749,7 @@ class Worksheet implements IComparable */ public function setBreak($coordinate, $break) { - $cellAddress = $this->validateCellAddress($coordinate); - - if ($cellAddress === '') { - throw new Exception('No cell coordinate specified.'); - } + $cellAddress = Functions::trimSheetFromCellReference($this->validateCellAddress($coordinate) ?? ''); if ($break === self::BREAK_NONE) { if (isset($this->breaks[$cellAddress])) { @@ -1738,18 +1793,15 @@ class Worksheet implements IComparable /** * Set merge on a cell range. * - * @param string $range Cell range (e.g. A1:E1) + * @param AddressRange|array|string $range A simple string containing a Cell range like 'A1:E10' + * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), + * or an AddressRange. * * @return $this */ public function mergeCells($range) { - // Uppercase coordinate - $range = strtoupper($range); - // Convert 'A:C' to 'A1:C1048576' - $range = self::pregReplace('/^([A-Z]+):([A-Z]+)$/', '${1}1:${2}1048576', $range); - // Convert '1:3' to 'A1:XFD3' - $range = self::pregReplace('/^(\\d+):(\\d+)$/', 'A${1}:XFD${2}', $range); + $range = Functions::trimSheetFromCellReference($this->validateCellRange($range)); if (preg_match('/^([A-Z]+)(\\d+):([A-Z]+)(\\d+)$/', $range, $matches) === 1) { $this->mergeCells[$range] = $range; @@ -1763,7 +1815,7 @@ class Worksheet implements IComparable $numberColumns = $lastColumnIndex - $firstColumnIndex; // create upper left cell if it does not already exist - $upperLeft = "$firstColumn$firstRow"; + $upperLeft = "{$firstColumn}{$firstRow}"; if (!$this->cellExists($upperLeft)) { $this->getCell($upperLeft)->setValueExplicit(null, DataType::TYPE_NULL); } @@ -1826,9 +1878,9 @@ class Worksheet implements IComparable * Set merge on a cell range by using numeric cell coordinates. * * @Deprecated 1.23.0 - * Use the mergeCells() method with a cell address range such as 'C5:E8' instead;, - * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5]), - * or a CellAddress object. + * Use the mergeCells() method with a cell address range such as 'C5:F8' instead;, + * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), + * or an AddressRange object. * * @param int $columnIndex1 Numeric column coordinate of the first cell * @param int $row1 Numeric row coordinate of the first cell @@ -1850,14 +1902,15 @@ class Worksheet implements IComparable /** * Remove merge on a cell range. * - * @param string $range Cell range (e.g. A1:E1) + * @param AddressRange|array|string $range A simple string containing a Cell range like 'A1:E10' + * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), + * or an AddressRange. * * @return $this */ public function unmergeCells($range) { - // Uppercase coordinate - $range = strtoupper($range); + $range = Functions::trimSheetFromCellReference($this->validateCellRange($range)); if (strpos($range, ':') !== false) { if (isset($this->mergeCells[$range])) { @@ -1875,6 +1928,11 @@ class Worksheet implements IComparable /** * Remove merge on a cell range by using numeric cell coordinates. * + * @Deprecated 1.23.0 + * Use the unmergeCells() method with a cell address range such as 'C5:F8' instead;, + * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), + * or an AddressRange object. + * * @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 last cell @@ -1918,9 +1976,11 @@ class Worksheet implements IComparable } /** - * Set protection on a cell range. + * Set protection on a cell or cell range. * - * @param string $range Cell (e.g. A1) or cell range (e.g. A1:E1) + * @param AddressRange|array|CellAddress|string $range A simple string containing a Cell range like 'A1:E10' + * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), + * or a CellAddress or AddressRange object. * @param string $password Password to unlock the protection * @param bool $alreadyHashed If the password has already been hashed, set this to true * @@ -1928,8 +1988,7 @@ class Worksheet implements IComparable */ public function protectCells($range, $password, $alreadyHashed = false) { - // Uppercase coordinate - $range = strtoupper($range); + $range = Functions::trimSheetFromCellReference($this->validateCellRange($range)); if (!$alreadyHashed) { $password = Shared\PasswordHasher::hashPassword($password); @@ -1942,6 +2001,11 @@ class Worksheet implements IComparable /** * Set protection on a cell range by using numeric cell coordinates. * + * @Deprecated 1.23.0 + * Use the protectCells() method with a cell address range such as 'C5:F8' instead;, + * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), + * or an AddressRange object. + * * @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 last cell @@ -1962,16 +2026,17 @@ class Worksheet implements IComparable } /** - * Remove protection on a cell range. + * Remove protection on a cell or cell range. * - * @param string $range Cell (e.g. A1) or cell range (e.g. A1:E1) + * @param AddressRange|array|CellAddress|string $range A simple string containing a Cell range like 'A1:E10' + * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), + * or a CellAddress or AddressRange object. * * @return $this */ public function unprotectCells($range) { - // Uppercase coordinate - $range = strtoupper($range); + $range = Functions::trimSheetFromCellReference($this->validateCellRange($range)); if (isset($this->protectedCells[$range])) { unset($this->protectedCells[$range]); @@ -1985,6 +2050,11 @@ class Worksheet implements IComparable /** * Remove protection on a cell range by using numeric cell coordinates. * + * @Deprecated 1.23.0 + * Use the protectCells() method with a cell address range such as 'C5:F8' instead;, + * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), + * or an AddressRange object. + * * @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 last cell @@ -2025,17 +2095,21 @@ class Worksheet implements IComparable /** * Set AutoFilter. * - * @param AutoFilter|string $autoFilterOrRange + * @param AddressRange|array|AutoFilter|string $autoFilterOrRange * A simple string containing a Cell range like 'A1:E10' is permitted for backward compatibility + * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), + * or an AddressRange. * * @return $this */ public function setAutoFilter($autoFilterOrRange) { - if (is_string($autoFilterOrRange)) { - $this->autoFilter->setRange($autoFilterOrRange); - } elseif (is_object($autoFilterOrRange) && ($autoFilterOrRange instanceof AutoFilter)) { + if (is_object($autoFilterOrRange) && ($autoFilterOrRange instanceof AutoFilter)) { $this->autoFilter = $autoFilterOrRange; + } else { + $cellRange = Functions::trimSheetFromCellReference($this->validateCellRange($autoFilterOrRange)); + + $this->autoFilter->setRange($cellRange); } return $this; @@ -2044,6 +2118,11 @@ class Worksheet implements IComparable /** * Set Autofilter Range by using numeric cell coordinates. * + * @Deprecated 1.23.0 + * Use the setAutoFilter() method with a cell address range such as 'C5:F8' instead;, + * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), + * or an AddressRange object or AutoFilter object. + * * @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 @@ -2053,11 +2132,12 @@ class Worksheet implements IComparable */ public function setAutoFilterByColumnAndRow($columnIndex1, $row1, $columnIndex2, $row2) { - return $this->setAutoFilter( - Coordinate::stringFromColumnIndex($columnIndex1) . $row1 - . ':' . - Coordinate::stringFromColumnIndex($columnIndex2) . $row2 + $cellRange = new CellRange( + CellAddress::fromColumnAndRow($columnIndex1, $row1), + CellAddress::fromColumnAndRow($columnIndex2, $row2) ); + + return $this->setAutoFilter($cellRange); } /** @@ -2090,9 +2170,11 @@ class Worksheet implements IComparable * - B2 will freeze the rows above and to the left of cell B2 (i.e row 1 and column A) * * @param null|array|CellAddress|string $coordinate Coordinate of the cell as a string, eg: 'C5'; - * or as an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object. - * Passing a null value for this argument will clear an existing freeze pane for this worksheet. - * @param null|string $topLeftCell default position of the right bottom pane + * or as an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object. + * Passing a null value for this argument will clear any existing freeze pane for this worksheet. + * @param null|array|CellAddress|string $topLeftCell default position of the right bottom pane + * Coordinate of the cell as a string, eg: 'C5'; or as an array of [$columnIndex, $row] (e.g. [3, 5]), + * or a CellAddress object. * * @return $this */ @@ -2102,6 +2184,7 @@ class Worksheet implements IComparable if (is_string($cellAddress) && Coordinate::coordinateIsRange($cellAddress)) { throw new Exception('Freeze pane can not be set on a range of cells.'); } + $topLeftCell = $this->validateCellAddress($topLeftCell, true); if ($cellAddress !== null && $topLeftCell === null) { $coordinate = Coordinate::coordinateFromString($cellAddress); diff --git a/tests/PhpSpreadsheetTests/Worksheet/ByColumnAndRowTest.php b/tests/PhpSpreadsheetTests/Worksheet/ByColumnAndRowTest.php new file mode 100644 index 00000000..d38be252 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Worksheet/ByColumnAndRowTest.php @@ -0,0 +1,188 @@ +getActiveSheet(); + + $sheet->setCellValueByColumnAndRow(2, 2, 2); + self::assertSame(2, $sheet->getCell('B2')->getValue()); + } + + public function testSetCellValueExplicitByColumnAndRow(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + + $sheet->setCellValueExplicitByColumnAndRow(2, 2, '="PHP Rules"', DataType::TYPE_STRING); + self::assertSame('="PHP Rules"', $sheet->getCell('B2')->getValue()); + self::assertSame(DataType::TYPE_STRING, $sheet->getCell('B2')->getDataType()); + } + + public function testCellExistsByColumnAndRow(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + + $cellExists = $sheet->cellExistsByColumnAndRow(2, 2); + self::assertFalse($cellExists); + + $sheet->setCellValue('B2', 2); + + $cellExists = $sheet->cellExistsByColumnAndRow(2, 2); + self::assertTrue($cellExists); + } + + public function testGetCellByColumnAndRow(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + + $sheet->setCellValue('B2', 2); + $cell = $sheet->getCellByColumnAndRow(2, 2); + self::assertSame('B2', $cell->getCoordinate()); + self::assertSame(2, $cell->getValue()); + } + + public function testGetStyleByColumnAndRow(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + + $data = [['A', 'B'], ['C', 'D']]; + $sheet->fromArray($data, null, 'B2', true); + $sheet->getStyle('B2:C3')->getFont()->setBold(true); + + $rangeStyle = $sheet->getStyleByColumnAndRow(2, 2, 3, 3); + self::assertTrue($rangeStyle->getFont()->getBold()); + + $cellStyle = $sheet->getStyleByColumnAndRow(2, 2); + self::assertTrue($cellStyle->getFont()->getBold()); + } + + public function testSetBreakByColumnAndRow(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + + $sheet->setCellValue('B2', 2); + $sheet->setBreakByColumnAndRow(2, 2, Worksheet::BREAK_COLUMN); + + $breaks = $sheet->getBreaks(); + self::assertArrayHasKey('B2', $breaks); + self::assertSame(Worksheet::BREAK_COLUMN, $breaks['B2']); + } + + public function testMergeCellsByColumnAndRow(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + + $data = [['A', 'B'], ['C', 'D']]; + $sheet->fromArray($data, null, 'B2', true); + + $sheet->mergeCellsByColumnAndRow(2, 2, 3, 3); + $mergeRanges = $sheet->getMergeCells(); + self::assertArrayHasKey('B2:C3', $mergeRanges); + } + + public function testUnergeCellsByColumnAndRow(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + + $data = [['A', 'B'], ['C', 'D']]; + $sheet->fromArray($data, null, 'B2', true); + + $sheet->mergeCells('B2:C3'); + $mergeRanges = $sheet->getMergeCells(); + self::assertArrayHasKey('B2:C3', $mergeRanges); + + $sheet->unmergeCellsByColumnAndRow(2, 2, 3, 3); + $mergeRanges = $sheet->getMergeCells(); + self::assertEmpty($mergeRanges); + } + + public function testProtectCellsByColumnAndRow(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + + $data = [['A', 'B'], ['C', 'D']]; + $sheet->fromArray($data, null, 'B2', true); + + $sheet->protectCellsByColumnAndRow(2, 2, 3, 3, 'secret', false); + $protectedRanges = $sheet->getProtectedCells(); + self::assertArrayHasKey('B2:C3', $protectedRanges); + } + + public function testUnprotectCellsByColumnAndRow(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + + $data = [['A', 'B'], ['C', 'D']]; + $sheet->fromArray($data, null, 'B2', true); + + $sheet->protectCells('B2:C3', 'secret', false); + $protectedRanges = $sheet->getProtectedCells(); + self::assertArrayHasKey('B2:C3', $protectedRanges); + + $sheet->unprotectCellsByColumnAndRow(2, 2, 3, 3); + $protectedRanges = $sheet->getProtectedCells(); + self::assertEmpty($protectedRanges); + } + + public function testSetAutoFilterByColumnAndRow(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + + $data = [['A', 'B'], ['C', 'D']]; + $sheet->fromArray($data, null, 'B2', true); + + $sheet->setAutoFilterByColumnAndRow(2, 2, 3, 3); + $autoFilter = $sheet->getAutoFilter(); + self::assertInstanceOf(AutoFilter::class, $autoFilter); + self::assertSame('B2:C3', $autoFilter->getRange()); + } + + public function testFreezePaneByColumnAndRow(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + + $data = [['A', 'B'], ['C', 'D']]; + $sheet->fromArray($data, null, 'B2', true); + + $sheet->freezePaneByColumnAndRow(2, 2); + $freezePane = $sheet->getFreezePane(); + self::assertSame('B2', $freezePane); + } + + public function testGetCommentByColumnAndRow(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + + $sheet->setCellValue('B2', 2); + $spreadsheet->getActiveSheet() + ->getComment('B2') + ->getText()->createTextRun('My Test Comment'); + + $comment = $sheet->getCommentByColumnAndRow(2, 2); + self::assertInstanceOf(Comment::class, $comment); + self::assertSame('My Test Comment', $comment->getText()->getPlainText()); + } +} From 3c3d949a5d1899521a6b45aecc5dc721ff5524d5 Mon Sep 17 00:00:00 2001 From: aswinkumar863 Date: Sun, 3 Apr 2022 18:23:13 +0530 Subject: [PATCH 06/52] Added table name validation Validation added for - invalid characters - invalid names ("C", "c", "R", or "r") - cell references - space separate words - maxlength of 255 characters - unique table names across worksheet --- samples/Table/01_Table.php | 2 +- src/PhpSpreadsheet/Worksheet/Table.php | 36 +++++++++- .../Worksheet/Table/TableTest.php | 71 +++++++++++++++++-- 3 files changed, 103 insertions(+), 6 deletions(-) diff --git a/samples/Table/01_Table.php b/samples/Table/01_Table.php index 405c07ae..c8301680 100644 --- a/samples/Table/01_Table.php +++ b/samples/Table/01_Table.php @@ -53,7 +53,7 @@ $spreadsheet->getActiveSheet()->fromArray($dataArray, null, 'A2'); // Create Table $helper->log('Create Table'); $table = new Table(); -$table->setName('Sales Data'); +$table->setName('Sales_Data'); $table->setRange('A1:D17'); // Create Columns diff --git a/src/PhpSpreadsheet/Worksheet/Table.php b/src/PhpSpreadsheet/Worksheet/Table.php index 30d659f1..950fe74c 100644 --- a/src/PhpSpreadsheet/Worksheet/Table.php +++ b/src/PhpSpreadsheet/Worksheet/Table.php @@ -88,7 +88,29 @@ class Table */ public function setName(string $name) { - $this->name = preg_replace('/\s+/', '_', trim($name)) ?? ''; + $name = trim($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('/^[A-Z_\\\\]/i', $name)) { + throw new PhpSpreadsheetException('The table name must begin a name with a letter, an underscore character (_), or a backslash (\)'); + } + if (!preg_match('/^[A-Z_\\\\][A-Z0-9\._]+$/i', $name)) { + throw new PhpSpreadsheetException('The table name contains invalid characters'); + } + + $this->name = $name; return $this; } @@ -216,6 +238,18 @@ class Table */ public function setWorksheet(?Worksheet $worksheet = null) { + if ($this->name != '' && $worksheet != null) { + $spreadsheet = $worksheet->getParent(); + + foreach ($spreadsheet->getWorksheetIterator() as $sheet) { + foreach ($sheet->getTableCollection() as $table) { + if ($table->getName() == $this->name) { + throw new PhpSpreadsheetException("Workbook already contains a table named '{$this->name}'"); + } + } + } + } + $this->workSheet = $worksheet; return $this; diff --git a/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php b/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php index c4365f7a..106955a8 100644 --- a/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php +++ b/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php @@ -22,15 +22,78 @@ class TableTest extends SetupTeardown self::assertEquals($expectedResult, $result); } - public function testVariousSets(): void + /** + * @dataProvider validTableNamesProvider + */ + public function testValidTableNames(string $name, string $expected): void { $sheet = $this->getSheet(); $table = new Table(self::INITIAL_RANGE, $sheet); - $result = $table->setName('Table 1'); + $result = $table->setName($name); self::assertInstanceOf(Table::class, $result); - // Spaces will be converted to underscore - self::assertEquals('Table_1', $table->getName()); + 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'], + ]; + } + + /** + * @dataProvider invalidTableNamesProvider + */ + public function testInvalidTableNames(string $name): void + { + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); + + $this->expectException(PhpSpreadsheetException::class); + + $table->setName($name); + } + + public function invalidTableNamesProvider(): array + { + return [ + ['C'], + ['c'], + ['R'], + ['r'], + ['Z100'], + ['Z$100'], + ['R1C1'], + ['R1C'], + ['R11C11'], + ['123'], + ['=Table'], + [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'); + $sheet->addTable($table2); + } + + public function testVariousSets(): void + { + $sheet = $this->getSheet(); + $table = new Table(self::INITIAL_RANGE, $sheet); $result = $table->setShowHeaderRow(false); self::assertInstanceOf(Table::class, $result); From 50b91e8ede3f25a4b9277405df56a58211445087 Mon Sep 17 00:00:00 2001 From: aswinkumar863 Date: Sun, 3 Apr 2022 18:24:38 +0530 Subject: [PATCH 07/52] Remove table By name Option to remove the table from table collection of worksheet --- src/PhpSpreadsheet/Worksheet/Worksheet.php | 18 +++++++++ .../Worksheet/Table/RemoveTableTest.php | 38 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 tests/PhpSpreadsheetTests/Worksheet/Table/RemoveTableTest.php diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index 08423356..e696e0d8 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -2057,6 +2057,24 @@ class Worksheet implements IComparable return $this->addTable(new Table($cellRange, $this)); } + /** + * Remove Table by name. + * + * @param string $name Table name + * + * @return $this + */ + public function removeTableByName(string $name): self + { + foreach($this->tableCollection as $key => $table) { + if ($table->getName() === $name) { + unset($this->tableCollection[$key]); + } + } + + return $this; + } + /** * Remove collection of Tables. */ diff --git a/tests/PhpSpreadsheetTests/Worksheet/Table/RemoveTableTest.php b/tests/PhpSpreadsheetTests/Worksheet/Table/RemoveTableTest.php new file mode 100644 index 00000000..b33e6c79 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Worksheet/Table/RemoveTableTest.php @@ -0,0 +1,38 @@ +getSheet(); + + $table = new Table(self::INITIAL_RANGE, $sheet); + $table->setName('Table1'); + $sheet->addTable($table); + + self::assertEquals(1, $sheet->getTableCollection()->count()); + + $sheet->removeTableByName('Table1'); + self::assertEquals(0, $sheet->getTableCollection()->count()); + } + + public function testRemoveCollection(): void + { + $sheet = $this->getSheet(); + + $table = new Table(self::INITIAL_RANGE, $sheet); + $table->setName('Table1'); + $sheet->addTable($table); + + self::assertEquals(1, $sheet->getTableCollection()->count()); + + $sheet->removeTableCollection(); + self::assertEquals(0, $sheet->getTableCollection()->count()); + } +} From bc6ec1932a4681fa880a8e25e4901be001a57be1 Mon Sep 17 00:00:00 2001 From: aswinkumar863 Date: Sun, 3 Apr 2022 18:27:00 +0530 Subject: [PATCH 08/52] Auto adjust table range using ReferenceHelper Automatically adjusts table range on insertion and deletion of rows and columns within table range --- src/PhpSpreadsheet/ReferenceHelper.php | 83 +++++++++++++++++++ src/PhpSpreadsheet/Worksheet/Table.php | 30 +++++++ .../Worksheet/Table/TableTest.php | 58 +++++++++++++ 3 files changed, 171 insertions(+) diff --git a/src/PhpSpreadsheet/ReferenceHelper.php b/src/PhpSpreadsheet/ReferenceHelper.php index 665b2e18..378a9a5a 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 index 950fe74c..04a34e6f 100644 --- a/src/PhpSpreadsheet/Worksheet/Table.php +++ b/src/PhpSpreadsheet/Worksheet/Table.php @@ -380,6 +380,36 @@ class Table 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) + * + * @return $this + */ + public function shiftColumn($fromColumn, $toColumn) + { + $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. * diff --git a/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php b/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php index 106955a8..1b1edb83 100644 --- a/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php +++ b/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php @@ -208,6 +208,64 @@ class TableTest extends SetupTeardown } } + 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); From feffb76944e631475f986785a1d320a146d5a3dd Mon Sep 17 00:00:00 2001 From: aswinkumar863 Date: Sun, 3 Apr 2022 18:27:38 +0530 Subject: [PATCH 09/52] Added Column Formula Option to add column formula that applied automatically for any new rows added to the table range --- samples/Table/03_Column_Formula.php | 72 +++++++++++++++++++ src/PhpSpreadsheet/Worksheet/Table/Column.php | 29 ++++++++ src/PhpSpreadsheet/Writer/Xlsx/Table.php | 4 ++ .../Worksheet/Table/ColumnTest.php | 5 ++ 4 files changed, 110 insertions(+) create mode 100644 samples/Table/03_Column_Formula.php diff --git a/samples/Table/03_Column_Formula.php b/samples/Table/03_Column_Formula.php new file mode 100644 index 00000000..e8ae5d99 --- /dev/null +++ b/samples/Table/03_Column_Formula.php @@ -0,0 +1,72 @@ +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(); +$table->setName('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/Worksheet/Table/Column.php b/src/PhpSpreadsheet/Worksheet/Table/Column.php index d9b87d80..06284c67 100644 --- a/src/PhpSpreadsheet/Worksheet/Table/Column.php +++ b/src/PhpSpreadsheet/Worksheet/Table/Column.php @@ -41,6 +41,13 @@ class Column */ private $totalsRowFormula; + /** + * Column Formula. + * + * @var string + */ + private $columnFormula; + /** * Table. * @@ -178,6 +185,28 @@ class Column return $this; } + /** + * Get column Formula. + * + * @return string + */ + public function getColumnFormula() + { + return $this->columnFormula; + } + + /** + * Set column Formula. + * + * @return $this + */ + public function setColumnFormula(string $columnFormula) + { + $this->columnFormula = $columnFormula; + + return $this; + } + /** * Get this Column's Table. * diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Table.php b/src/PhpSpreadsheet/Writer/Xlsx/Table.php index e7adfd3c..be9f5183 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Table.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Table.php @@ -86,6 +86,10 @@ class Table extends WriterPart $objWriter->writeAttribute('totalsRowFunction', $column->getTotalsRowFunction()); } } + if ($column->getColumnFormula()) { + $objWriter->writeElement('calculatedColumnFormula', $column->getColumnFormula()); + } + $objWriter->endElement(); } $objWriter->endElement(); diff --git a/tests/PhpSpreadsheetTests/Worksheet/Table/ColumnTest.php b/tests/PhpSpreadsheetTests/Worksheet/Table/ColumnTest.php index 195d6e41..c7edd86a 100644 --- a/tests/PhpSpreadsheetTests/Worksheet/Table/ColumnTest.php +++ b/tests/PhpSpreadsheetTests/Worksheet/Table/ColumnTest.php @@ -74,6 +74,11 @@ class ColumnTest extends SetupTeardown $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 From 4db82032b4c8afebb2cf1178f2dd4a4b69785ff8 Mon Sep 17 00:00:00 2001 From: aswinkumar863 Date: Sun, 3 Apr 2022 18:51:06 +0530 Subject: [PATCH 10/52] Remove table by name cs fix --- src/PhpSpreadsheet/Worksheet/Worksheet.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index e696e0d8..971f2a98 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -2066,7 +2066,7 @@ class Worksheet implements IComparable */ public function removeTableByName(string $name): self { - foreach($this->tableCollection as $key => $table) { + foreach ($this->tableCollection as $key => $table) { if ($table->getName() === $name) { unset($this->tableCollection[$key]); } From 83aaf32161649a04ca2c23578fe72273da27f507 Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Sun, 3 Apr 2022 21:49:25 +0200 Subject: [PATCH 11/52] Handle selected cells --- src/PhpSpreadsheet/Worksheet/Worksheet.php | 129 ++++++++++----------- 1 file changed, 60 insertions(+), 69 deletions(-) diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index 2f8f6972..a1bfbfdb 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -1113,12 +1113,9 @@ class Worksheet implements IComparable * @param null|array|CellAddress|string $cellAddress Coordinate of the cell as a string, eg: 'C5'; * or as an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object. */ - protected function validateCellAddress($cellAddress, bool $allowNull = false): ?string + protected function validateCellAddress($cellAddress): string { - if (is_string($cellAddress) || ($cellAddress === null && $allowNull === true)) { - if ($cellAddress === null) { - return null; - } + if (is_string($cellAddress)) { [$worksheet, $address] = self::extractSheetTitle($cellAddress, true); // if (!empty($worksheet) && $worksheet !== $this->getTitle()) { // throw new Exception('Reference is not for this worksheet'); @@ -1134,16 +1131,38 @@ class Worksheet implements IComparable return (string) $cellAddress; } + private function tryDefinedName(string $coordinate): string + { + // Uppercase coordinate + $coordinate = strtoupper($coordinate); + // Eliminate leading equal sign + $coordinate = self::pregReplace('/^=/', '', $coordinate); + $defined = $this->parent->getDefinedName($coordinate, $this); + if ($defined !== null) { + if ($defined->getWorksheet() === $this && !$defined->isFormula()) { + $coordinate = self::pregReplace('/^=/', '', $defined->getValue()); + } + } + + return $coordinate; + } + /** - * Validate a cell range. + * Validate a cell address or cell range. * - * @param AddressRange|array|CellAddress|string $cellRange Coordinate of the cells as a string, eg: 'C5:F12'; + * @param AddressRange|array|CellAddress|int|string $cellRange Coordinate of the cells as a string, eg: 'C5:F12'; * or as an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 12]), * or as a CellAddress or AddressRange object. */ protected function validateCellOrCellRange($cellRange): string { - if (is_object($cellRange) && $cellRange instanceof CellAddress) { + if (is_string($cellRange) || is_numeric($cellRange)) { + $cellRange = (string) $cellRange; + // Convert a single column reference like 'A' to 'A:A' + $cellRange = self::pregReplace('/^([A-Z]+)$/', '${1}:${1}', $cellRange); + // Convert a single row reference like '1' to '1:1' + $cellRange = self::pregReplace('/^(\d+)$/', '${1}:${1}', $cellRange); + } elseif (is_object($cellRange) && $cellRange instanceof CellAddress) { $cellRange = new CellRange($cellRange, $cellRange); } @@ -1162,9 +1181,9 @@ class Worksheet implements IComparable if (is_string($cellRange)) { [$worksheet, $addressRange] = self::extractSheetTitle($cellRange, true); - // Convert 'A:C' to 'A1:C1048576' + // Convert Column ranges like 'A:C' to 'A1:C1048576' $addressRange = self::pregReplace('/^([A-Z]+):([A-Z]+)$/', '${1}1:${2}1048576', $addressRange); - // Convert '1:3' to 'A1:XFD3' + // Convert Row ranges like '1:3' to 'A1:XFD3' $addressRange = self::pregReplace('/^(\\d+):(\\d+)$/', 'A${1}:XFD${2}', $addressRange); return empty($worksheet) ? strtoupper($addressRange) : $worksheet . '!' . strtoupper($addressRange); @@ -1189,8 +1208,7 @@ class Worksheet implements IComparable */ public function setCellValue($coordinate, $value) { - /** @var string $cellAddress */ - $cellAddress = $this->validateCellAddress($coordinate); + $cellAddress = Functions::trimSheetFromCellReference($this->validateCellAddress($coordinate)); $this->getCell($cellAddress)->setValue($value); return $this; @@ -1211,7 +1229,7 @@ class Worksheet implements IComparable */ public function setCellValueByColumnAndRow($columnIndex, $row, $value) { - $this->getCell([$columnIndex, $row])->setValue($value); + $this->getCell(Coordinate::stringFromColumnIndex($columnIndex) . $row)->setValue($value); return $this; } @@ -1228,8 +1246,7 @@ class Worksheet implements IComparable */ public function setCellValueExplicit($coordinate, $value, $dataType) { - /** @var string $cellAddress */ - $cellAddress = $this->validateCellAddress($coordinate); + $cellAddress = Functions::trimSheetFromCellReference($this->validateCellAddress($coordinate)); $this->getCell($cellAddress)->setValueExplicit($value, $dataType); return $this; @@ -1251,7 +1268,7 @@ class Worksheet implements IComparable */ public function setCellValueExplicitByColumnAndRow($columnIndex, $row, $value, $dataType) { - $this->getCell([$columnIndex, $row])->setValueExplicit($value, $dataType); + $this->getCell(Coordinate::stringFromColumnIndex($columnIndex) . $row)->setValueExplicit($value, $dataType); return $this; } @@ -1266,8 +1283,7 @@ class Worksheet implements IComparable */ public function getCell($coordinate): Cell { - /** @var string $cellAddress */ - $cellAddress = $this->validateCellAddress($coordinate); + $cellAddress = Functions::trimSheetFromCellReference($this->validateCellAddress($coordinate)); // Shortcut for increased performance for the vast majority of simple cases if ($this->cellCollection->has($cellAddress)) { @@ -1368,7 +1384,7 @@ class Worksheet implements IComparable */ public function getCellByColumnAndRow($columnIndex, $row): Cell { - return $this->getCell([$columnIndex, $row]); + return $this->getCell(Coordinate::stringFromColumnIndex($columnIndex) . $row); } /** @@ -1418,7 +1434,6 @@ class Worksheet implements IComparable */ public function cellExists($coordinate): bool { - /** @var string $cellAddress */ $cellAddress = $this->validateCellAddress($coordinate); /** @var Worksheet $sheet */ [$sheet, $finalCoordinate] = $this->getWorksheetAndCoordinate($cellAddress); @@ -1438,7 +1453,7 @@ class Worksheet implements IComparable */ public function cellExistsByColumnAndRow($columnIndex, $row): bool { - return $this->cellExists([$columnIndex, $row]); + return $this->cellExists(Coordinate::stringFromColumnIndex($columnIndex) . $row); } /** @@ -1504,14 +1519,14 @@ class Worksheet implements IComparable /** * Get style for cell. * - * @param AddressRange|array|CellAddress|string $cellCoordinate + * @param AddressRange|array|CellAddress|int|string $cellCoordinate * A simple string containing a cell address like 'A1' or a cell range like 'A1:E10' * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), * or a CellAddress or AddressRange object. */ public function getStyle($cellCoordinate): Style { - $cellCoordinate = $this->validateCellRange($cellCoordinate); + $cellCoordinate = $this->validateCellOrCellRange($cellCoordinate); // set this sheet as active $this->parent->setActiveSheetIndex($this->parent->getIndex($this)); @@ -1749,7 +1764,7 @@ class Worksheet implements IComparable */ public function setBreak($coordinate, $break) { - $cellAddress = Functions::trimSheetFromCellReference($this->validateCellAddress($coordinate) ?? ''); + $cellAddress = Functions::trimSheetFromCellReference($this->validateCellAddress($coordinate)); if ($break === self::BREAK_NONE) { if (isset($this->breaks[$cellAddress])) { @@ -1777,7 +1792,7 @@ class Worksheet implements IComparable */ public function setBreakByColumnAndRow($columnIndex, $row, $break) { - return $this->setBreak([$columnIndex, $row], $break); + return $this->setBreak(Coordinate::stringFromColumnIndex($columnIndex) . $row, $break); } /** @@ -1978,7 +1993,7 @@ class Worksheet implements IComparable /** * Set protection on a cell or cell range. * - * @param AddressRange|array|CellAddress|string $range A simple string containing a Cell range like 'A1:E10' + * @param AddressRange|array|CellAddress|int|string $range A simple string containing a Cell range like 'A1:E10' * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), * or a CellAddress or AddressRange object. * @param string $password Password to unlock the protection @@ -1988,7 +2003,7 @@ class Worksheet implements IComparable */ public function protectCells($range, $password, $alreadyHashed = false) { - $range = Functions::trimSheetFromCellReference($this->validateCellRange($range)); + $range = Functions::trimSheetFromCellReference($this->validateCellOrCellRange($range)); if (!$alreadyHashed) { $password = Shared\PasswordHasher::hashPassword($password); @@ -2028,7 +2043,7 @@ class Worksheet implements IComparable /** * Remove protection on a cell or cell range. * - * @param AddressRange|array|CellAddress|string $range A simple string containing a Cell range like 'A1:E10' + * @param AddressRange|array|CellAddress|int|string $range A simple string containing a Cell range like 'A1:E10' * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), * or a CellAddress or AddressRange object. * @@ -2036,7 +2051,7 @@ class Worksheet implements IComparable */ public function unprotectCells($range) { - $range = Functions::trimSheetFromCellReference($this->validateCellRange($range)); + $range = Functions::trimSheetFromCellReference($this->validateCellOrCellRange($range)); if (isset($this->protectedCells[$range])) { unset($this->protectedCells[$range]); @@ -2180,11 +2195,15 @@ class Worksheet implements IComparable */ public function freezePane($coordinate, $topLeftCell = null) { - $cellAddress = $this->validateCellAddress($coordinate, true); - if (is_string($cellAddress) && Coordinate::coordinateIsRange($cellAddress)) { + $cellAddress = ($coordinate !== null) + ? Functions::trimSheetFromCellReference($this->validateCellAddress($coordinate)) + : null; + if ($cellAddress !== null && Coordinate::coordinateIsRange($cellAddress)) { throw new Exception('Freeze pane can not be set on a range of cells.'); } - $topLeftCell = $this->validateCellAddress($topLeftCell, true); + $topLeftCell = ($topLeftCell !== null) + ? Functions::trimSheetFromCellReference($this->validateCellAddress($topLeftCell)) + : null; if ($cellAddress !== null && $topLeftCell === null) { $coordinate = Coordinate::coordinateFromString($cellAddress); @@ -2218,7 +2237,7 @@ class Worksheet implements IComparable */ public function freezePaneByColumnAndRow($columnIndex, $row) { - return $this->freezePane([$columnIndex, $row]); + return $this->freezePane(Coordinate::stringFromColumnIndex($columnIndex) . $row); } /** @@ -2587,8 +2606,7 @@ class Worksheet implements IComparable */ public function getComment($cellCoordinate) { - /** @var string $cellAddress */ - $cellAddress = $this->validateCellAddress($cellCoordinate); + $cellAddress = Functions::trimSheetFromCellReference($this->validateCellAddress($cellCoordinate)); if (Coordinate::coordinateIsRange($cellAddress)) { throw new Exception('Cell coordinate string can not be a range of cells.'); @@ -2624,7 +2642,7 @@ class Worksheet implements IComparable */ public function getCommentByColumnAndRow($columnIndex, $row) { - return $this->getComment([$columnIndex, $row]); + return $this->getComment(Coordinate::stringFromColumnIndex($columnIndex) . $row); } /** @@ -2675,48 +2693,21 @@ class Worksheet implements IComparable return self::ensureString(preg_replace($pattern, $replacement, $subject)); } - private function tryDefinedName(string $coordinate): string - { - // Uppercase coordinate - $coordinate = strtoupper($coordinate); - // Eliminate leading equal sign - $coordinate = self::pregReplace('/^=/', '', $coordinate); - $defined = $this->parent->getDefinedName($coordinate, $this); - if ($defined !== null) { - if ($defined->getWorksheet() === $this && !$defined->isFormula()) { - $coordinate = self::pregReplace('/^=/', '', $defined->getValue()); - } - } - - return $coordinate; - } - /** * Select a range of cells. * - * @param string $coordinate Cell range, examples: 'A1', 'B2:G5', 'A:C', '3:6' + * @param AddressRange|array|CellAddress|int|string $coordinate A simple string containing a Cell range like 'A1:E10' + * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), + * or a CellAddress or AddressRange object. * * @return $this */ public function setSelectedCells($coordinate) { - $originalCoordinate = $coordinate; - $coordinate = $this->tryDefinedName($coordinate); - - // Convert 'A' to 'A:A' - $coordinate = self::pregReplace('/^([A-Z]+)$/', '${1}:${1}', $coordinate); - - // Convert '1' to '1:1' - $coordinate = self::pregReplace('/^(\d+)$/', '${1}:${1}', $coordinate); - - // Convert 'A:C' to 'A1:C1048576' - $coordinate = self::pregReplace('/^([A-Z]+):([A-Z]+)$/', '${1}1:${2}1048576', $coordinate); - - // Convert '1:3' to 'A1:XFD3' - $coordinate = self::pregReplace('/^(\d+):(\d+)$/', 'A${1}:XFD${2}', $coordinate); - if (preg_match('/^\\$?[A-Z]{1,3}\\$?\d{1,7}(:\\$?[A-Z]{1,3}\\$?\d{1,7})?$/', $coordinate) !== 1) { - throw new Exception("Invalid setSelectedCells $originalCoordinate $coordinate"); + if (is_string($coordinate)) { + $coordinate = $this->tryDefinedName($coordinate); } + $coordinate = $this->validateCellOrCellRange($coordinate); if (Coordinate::coordinateIsRange($coordinate)) { [$first] = Coordinate::splitRange($coordinate); From 812c14a6adc8761853a303bb067e294d148124ca Mon Sep 17 00:00:00 2001 From: mjan4175 Date: Mon, 11 Apr 2022 08:50:28 +0200 Subject: [PATCH 12/52] Fix invalid styles in empty columns of added external sheet. --- src/PhpSpreadsheet/Spreadsheet.php | 5 +++ tests/PhpSpreadsheetTests/SpreadsheetTest.php | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/PhpSpreadsheet/Spreadsheet.php b/src/PhpSpreadsheet/Spreadsheet.php index 52d7fb55..756efee6 100644 --- a/src/PhpSpreadsheet/Spreadsheet.php +++ b/src/PhpSpreadsheet/Spreadsheet.php @@ -869,6 +869,11 @@ class Spreadsheet $cell->setXfIndex($cell->getXfIndex() + $countCellXfs); } + // update the column dimensions Xfs + foreach ($worksheet->getColumnDimensions() as $columnDimension) { + $columnDimension->setXfIndex($columnDimension->getXfIndex() + $countCellXfs); + } + return $this->addSheet($worksheet, $sheetIndex); } diff --git a/tests/PhpSpreadsheetTests/SpreadsheetTest.php b/tests/PhpSpreadsheetTests/SpreadsheetTest.php index 4a1b7393..a91e0ffa 100644 --- a/tests/PhpSpreadsheetTests/SpreadsheetTest.php +++ b/tests/PhpSpreadsheetTests/SpreadsheetTest.php @@ -188,4 +188,35 @@ class SpreadsheetTest extends TestCase $sheet->getCell('A1')->getStyle()->getFont()->setBold(true); $this->object->addExternalSheet($sheet); } + + public function testAddExternalColumnDimensionStyles(): void + { + $spreadsheet1 = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); + $sheet1 = $spreadsheet1->createSheet()->setTitle('sheetWithColumnDimension'); + $sheet1->getCell('A1')->setValue(1); + $sheet1->getCell('A1')->getStyle()->getFont()->setItalic(true); + $sheet1->getColumnDimension('B')->setWidth('10')->setXfIndex($sheet1->getCell('A1')->getXfIndex()); + $index = $sheet1->getColumnDimension('B')->getXfIndex(); + self::assertEquals(1, $index); + self::assertCount(2, $spreadsheet1->getCellXfCollection()); + + $spreadsheet2 = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); + $sheet2 = $spreadsheet2->createSheet()->setTitle('sheetWithTwoStyles'); + $sheet2->getCell('A1')->setValue(1); + $sheet2->getCell('A1')->getStyle()->getFont()->setBold(true); + $sheet2->getCell('B2')->getStyle()->getFont()->setSuperscript(true); + $countXfs = count($spreadsheet2->getCellXfCollection()); + self::assertEquals(3, $countXfs); + + $sheet3 = $spreadsheet2->addExternalSheet($sheet1); + self::assertCount(5, $spreadsheet2->getCellXfCollection()); + self::assertTrue($sheet3->getCell('A1')->getStyle()->getFont()->getItalic()); + self::assertTrue($sheet3->getCell('B1')->getStyle()->getFont()->getItalic()); + self::assertFalse($sheet3->getCell('B1')->getStyle()->getFont()->getBold()); + // Prove Xf index changed although style is same. + self::assertEquals($countXfs + $index, $sheet3->getCell('B1')->getXfIndex()); + self::assertEquals($countXfs + $index, $sheet3->getColumnDimension('B')->getXfIndex()); + } + + } From bb4a22d5e97795c6a5606c1b7aef7047160bb242 Mon Sep 17 00:00:00 2001 From: mjan4175 Date: Mon, 11 Apr 2022 08:56:42 +0200 Subject: [PATCH 13/52] FIX: Coding style in tests --- tests/PhpSpreadsheetTests/SpreadsheetTest.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/PhpSpreadsheetTests/SpreadsheetTest.php b/tests/PhpSpreadsheetTests/SpreadsheetTest.php index a91e0ffa..19febb79 100644 --- a/tests/PhpSpreadsheetTests/SpreadsheetTest.php +++ b/tests/PhpSpreadsheetTests/SpreadsheetTest.php @@ -217,6 +217,4 @@ class SpreadsheetTest extends TestCase self::assertEquals($countXfs + $index, $sheet3->getCell('B1')->getXfIndex()); self::assertEquals($countXfs + $index, $sheet3->getColumnDimension('B')->getXfIndex()); } - - } From 31ca617570728aeb5b85fe1fdaf87f11e18ab7e1 Mon Sep 17 00:00:00 2001 From: mjan4175 Date: Mon, 11 Apr 2022 09:06:38 +0200 Subject: [PATCH 14/52] FIX: Data types in tests --- tests/PhpSpreadsheetTests/SpreadsheetTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PhpSpreadsheetTests/SpreadsheetTest.php b/tests/PhpSpreadsheetTests/SpreadsheetTest.php index 19febb79..7e987313 100644 --- a/tests/PhpSpreadsheetTests/SpreadsheetTest.php +++ b/tests/PhpSpreadsheetTests/SpreadsheetTest.php @@ -195,7 +195,7 @@ class SpreadsheetTest extends TestCase $sheet1 = $spreadsheet1->createSheet()->setTitle('sheetWithColumnDimension'); $sheet1->getCell('A1')->setValue(1); $sheet1->getCell('A1')->getStyle()->getFont()->setItalic(true); - $sheet1->getColumnDimension('B')->setWidth('10')->setXfIndex($sheet1->getCell('A1')->getXfIndex()); + $sheet1->getColumnDimension('B')->setWidth(10)->setXfIndex($sheet1->getCell('A1')->getXfIndex()); $index = $sheet1->getColumnDimension('B')->getXfIndex(); self::assertEquals(1, $index); self::assertCount(2, $spreadsheet1->getCellXfCollection()); From 43e0e64cd40aaf4a880e483bca06c79a9b8bb64c Mon Sep 17 00:00:00 2001 From: mjan4175 Date: Mon, 11 Apr 2022 09:31:07 +0200 Subject: [PATCH 15/52] CHANGELOG.md contribution --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7ae1854..3ea07a96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). Nor is this a perfect solution, as there may still be issues when function calls have array arguments that themselves contain function calls; but it's still better than the current logic. - Fix for escaping double quotes within a formula [Issue #1971](https://github.com/PHPOffice/PhpSpreadsheet/issues/1971) [PR #2651](https://github.com/PHPOffice/PhpSpreadsheet/pull/2651) +- Fix invalid style of cells in empty columns with columnDimensions in added external sheet. [PR #2739](https://github.com/PHPOffice/PhpSpreadsheet/pull/2739) ## 1.22.0 - 2022-02-18 @@ -292,7 +293,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Changed -- Use of `nb` rather than `no` as the locale code for Norsk Bokmål. +- Use of `nb` rather than `no` as the locale code for Norsk Bokmal. ### Deprecated @@ -300,7 +301,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Removed -- Use of `nb` rather than `no` as the locale language code for Norsk Bokmål. +- Use of `nb` rather than `no` as the locale language code for Norsk Bokmal. ### Fixed From 5719b213410bcc4725da1002daefb2647253b07b Mon Sep 17 00:00:00 2001 From: mjan4175 Date: Mon, 11 Apr 2022 09:39:53 +0200 Subject: [PATCH 16/52] FIX: Changelog revert unwanted changes --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ea07a96..05cd3604 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). - Fix for escaping double quotes within a formula [Issue #1971](https://github.com/PHPOffice/PhpSpreadsheet/issues/1971) [PR #2651](https://github.com/PHPOffice/PhpSpreadsheet/pull/2651) - Fix invalid style of cells in empty columns with columnDimensions in added external sheet. [PR #2739](https://github.com/PHPOffice/PhpSpreadsheet/pull/2739) + ## 1.22.0 - 2022-02-18 ### Added @@ -293,7 +294,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Changed -- Use of `nb` rather than `no` as the locale code for Norsk Bokmal. +- Use of `nb` rather than `no` as the locale code for Norsk Bokmål. ### Deprecated @@ -301,7 +302,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Removed -- Use of `nb` rather than `no` as the locale language code for Norsk Bokmal. +- Use of `nb` rather than `no` as the locale language code for Norsk Bokmål. ### Fixed From f67273425842adf88fcc8df0fdac5c52d8680e93 Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Tue, 12 Apr 2022 08:34:03 +0200 Subject: [PATCH 17/52] Typo --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35ed928e..3af4e647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,7 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). - `setSelectedCellByColumnAndRow()` use the equivalent `setSelectedCells()` This change provides more consistency in the methods (not every "by cell address" method has an equivalent "byColumnAndRow" method); - and the "by cell address" methods often provide more flexibility, such as allowing a range of cells, or referencig them by passing the defined name of a named range as the argument. + and the "by cell address" methods often provide more flexibility, such as allowing a range of cells, or referencing them by passing the defined name of a named range as the argument. ### Removed From e41fdf490950d9d409c5e66e9ffcfdcd607795c6 Mon Sep 17 00:00:00 2001 From: mjan4175 Date: Tue, 12 Apr 2022 13:38:19 +0200 Subject: [PATCH 18/52] FIX: Invalid styles in rowDimensions of added external sheet. --- src/PhpSpreadsheet/Spreadsheet.php | 8 +++++ tests/PhpSpreadsheetTests/SpreadsheetTest.php | 29 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/PhpSpreadsheet/Spreadsheet.php b/src/PhpSpreadsheet/Spreadsheet.php index 756efee6..3c898928 100644 --- a/src/PhpSpreadsheet/Spreadsheet.php +++ b/src/PhpSpreadsheet/Spreadsheet.php @@ -874,6 +874,14 @@ class Spreadsheet $columnDimension->setXfIndex($columnDimension->getXfIndex() + $countCellXfs); } + // update the row dimensions Xfs + foreach ($worksheet->getRowDimensions() as $rowDimension) { + $xfIndex = $rowDimension->getXfIndex(); + if ($xfIndex !== null) { + $rowDimension->setXfIndex($xfIndex + $countCellXfs); + } + } + return $this->addSheet($worksheet, $sheetIndex); } diff --git a/tests/PhpSpreadsheetTests/SpreadsheetTest.php b/tests/PhpSpreadsheetTests/SpreadsheetTest.php index 7e987313..11fb56e4 100644 --- a/tests/PhpSpreadsheetTests/SpreadsheetTest.php +++ b/tests/PhpSpreadsheetTests/SpreadsheetTest.php @@ -217,4 +217,33 @@ class SpreadsheetTest extends TestCase self::assertEquals($countXfs + $index, $sheet3->getCell('B1')->getXfIndex()); self::assertEquals($countXfs + $index, $sheet3->getColumnDimension('B')->getXfIndex()); } + + public function testAddExternalRowDimensionStyles(): void + { + $spreadsheet1 = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); + $sheet1 = $spreadsheet1->createSheet()->setTitle('sheetWithColumnDimension'); + $sheet1->getCell('A1')->setValue(1); + $sheet1->getCell('A1')->getStyle()->getFont()->setItalic(true); + $sheet1->getRowDimension(2)->setXfIndex($sheet1->getCell('A1')->getXfIndex()); + $index = $sheet1->getRowDimension(2)->getXfIndex(); + self::assertEquals(1, $index); + self::assertCount(2, $spreadsheet1->getCellXfCollection()); + + $spreadsheet2 = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); + $sheet2 = $spreadsheet2->createSheet()->setTitle('sheetWithTwoStyles'); + $sheet2->getCell('A1')->setValue(1); + $sheet2->getCell('A1')->getStyle()->getFont()->setBold(true); + $sheet2->getCell('B2')->getStyle()->getFont()->setSuperscript(true); + $countXfs = count($spreadsheet2->getCellXfCollection()); + self::assertEquals(3, $countXfs); + + $sheet3 = $spreadsheet2->addExternalSheet($sheet1); + self::assertCount(5, $spreadsheet2->getCellXfCollection()); + self::assertTrue($sheet3->getCell('A1')->getStyle()->getFont()->getItalic()); + self::assertTrue($sheet3->getCell('A2')->getStyle()->getFont()->getItalic()); + self::assertFalse($sheet3->getCell('A2')->getStyle()->getFont()->getBold()); + // Prove Xf index changed although style is same. + self::assertEquals($countXfs + $index, $sheet3->getCell('A2')->getXfIndex()); + self::assertEquals($countXfs + $index, $sheet3->getRowDimension(2)->getXfIndex()); + } } From 5f317250b38be4d6dd5a5d408ed7ee6287d1bfd5 Mon Sep 17 00:00:00 2001 From: mjan4175 Date: Tue, 12 Apr 2022 19:43:42 +0200 Subject: [PATCH 19/52] CHANGELOG.md contribution --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05cd3604..1d562455 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,7 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). Nor is this a perfect solution, as there may still be issues when function calls have array arguments that themselves contain function calls; but it's still better than the current logic. - Fix for escaping double quotes within a formula [Issue #1971](https://github.com/PHPOffice/PhpSpreadsheet/issues/1971) [PR #2651](https://github.com/PHPOffice/PhpSpreadsheet/pull/2651) -- Fix invalid style of cells in empty columns with columnDimensions in added external sheet. [PR #2739](https://github.com/PHPOffice/PhpSpreadsheet/pull/2739) +- Fix invalid style of cells in empty columns with columnDimensions and rows with rowDimensions in added external sheet. [PR #2739](https://github.com/PHPOffice/PhpSpreadsheet/pull/2739) ## 1.22.0 - 2022-02-18 From c214a199d6566b8a0f07f5e4596067d33950cb53 Mon Sep 17 00:00:00 2001 From: mjan4175 Date: Tue, 12 Apr 2022 19:53:21 +0200 Subject: [PATCH 20/52] FIX: Coding style --- src/PhpSpreadsheet/Spreadsheet.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PhpSpreadsheet/Spreadsheet.php b/src/PhpSpreadsheet/Spreadsheet.php index 3c898928..33b4fe0c 100644 --- a/src/PhpSpreadsheet/Spreadsheet.php +++ b/src/PhpSpreadsheet/Spreadsheet.php @@ -881,7 +881,7 @@ class Spreadsheet $rowDimension->setXfIndex($xfIndex + $countCellXfs); } } - + return $this->addSheet($worksheet, $sheetIndex); } From 8c84ce439986989e41a9b05a25181f78413ebbe6 Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Tue, 12 Apr 2022 17:04:07 +0200 Subject: [PATCH 21/52] Support for chained range operators in the Calculation Engine (e.g. `A3:B1:C2` which gives an effective combined range of `A1:C3` or `A5:C10:C20:F1` which gives an effective combined range of `A1:F20`). Next step will be allowing Named Cells/Ranges to be chained in the same way. --- phpstan-baseline.neon | 2 +- .../Calculation/Calculation.php | 40 ++++++----- .../Calculation/Engine/RangeTest.php | 72 +++++++++---------- 3 files changed, 60 insertions(+), 54 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 22e7c803..12329a72 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -127,7 +127,7 @@ parameters: - message: "#^Offset 'value' does not exist on array\\|null\\.$#" - count: 3 + count: 5 path: src/PhpSpreadsheet/Calculation/Calculation.php - diff --git a/src/PhpSpreadsheet/Calculation/Calculation.php b/src/PhpSpreadsheet/Calculation/Calculation.php index b336920c..c14f5252 100644 --- a/src/PhpSpreadsheet/Calculation/Calculation.php +++ b/src/PhpSpreadsheet/Calculation/Calculation.php @@ -33,11 +33,11 @@ class Calculation // Function (allow for the old @ symbol that could be used to prefix a function, but we'll ignore it) const CALCULATION_REGEXP_FUNCTION = '@?(?:_xlfn\.)?([\p{L}][\p{L}\p{N}\.]*)[\s]*\('; // Cell reference (cell or range of cells, with or without a sheet reference) - const CALCULATION_REGEXP_CELLREF = '((([^\s,!&%^\/\*\+<>=-]*)|(\'.*?\')|(\".*?\"))!)?\$?\b([a-z]{1,3})\$?(\d{1,7})(?![\w.])'; + const CALCULATION_REGEXP_CELLREF = '((([^\s,!&%^\/\*\+<>=:`-]*)|(\'.*?\')|(\".*?\"))!)?\$?\b([a-z]{1,3})\$?(\d{1,7})(?![\w.])'; // Cell reference (with or without a sheet reference) ensuring absolute/relative - const CALCULATION_REGEXP_CELLREF_RELATIVE = '((([^\s\(,!&%^\/\*\+<>=-]*)|(\'.*?\')|(\".*?\"))!)?(\$?\b[a-z]{1,3})(\$?\d{1,7})(?![\w.])'; - const CALCULATION_REGEXP_COLUMN_RANGE = '(((([^\s\(,!&%^\/\*\+<>=-]*)|(\'.*?\')|(\".*?\"))!)?(\$?[a-z]{1,3})):(?![.*])'; - const CALCULATION_REGEXP_ROW_RANGE = '(((([^\s\(,!&%^\/\*\+<>=-]*)|(\'.*?\')|(\".*?\"))!)?(\$?[1-9][0-9]{0,6})):(?![.*])'; + const CALCULATION_REGEXP_CELLREF_RELATIVE = '((([^\s\(,!&%^\/\*\+<>=:`-]*)|(\'.*?\')|(\".*?\"))!)?(\$?\b[a-z]{1,3})(\$?\d{1,7})(?![\w.])'; + const CALCULATION_REGEXP_COLUMN_RANGE = '(((([^\s\(,!&%^\/\*\+<>=:`-]*)|(\'.*?\')|(\".*?\"))!)?(\$?[a-z]{1,3})):(?![.*])'; + const CALCULATION_REGEXP_ROW_RANGE = '(((([^\s\(,!&%^\/\*\+<>=:`-]*)|(\'.*?\')|(\".*?\"))!)?(\$?[1-9][0-9]{0,6})):(?![.*])'; // Cell reference (with or without a sheet reference) ensuring absolute/relative // Cell ranges ensuring absolute/relative const CALCULATION_REGEXP_COLUMNRANGE_RELATIVE = '(\$?[a-z]{1,3}):(\$?[a-z]{1,3})'; @@ -4135,17 +4135,25 @@ class Calculation $testPrevOp = $stack->last(1); if ($testPrevOp !== null && $testPrevOp['value'] === ':') { // If we have a worksheet reference, then we're playing with a 3D reference - if ($matches[2] == '') { + if ($matches[2] === '') { // Otherwise, we 'inherit' the worksheet reference from the start cell reference // The start of the cell range reference should be the last entry in $output $rangeStartCellRef = $output[count($output) - 1]['value']; - preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '$/i', $rangeStartCellRef, $rangeStartMatches); + if ($rangeStartCellRef === ':') { + // Do we have chained range operators? + $rangeStartCellRef = $output[count($output) - 2]['value']; + } + preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '$/miu', $rangeStartCellRef, $rangeStartMatches); if ($rangeStartMatches[2] > '') { $val = $rangeStartMatches[2] . '!' . $val; } } else { $rangeStartCellRef = $output[count($output) - 1]['value']; - preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '$/i', $rangeStartCellRef, $rangeStartMatches); + if ($rangeStartCellRef === ':') { + // Do we have chained range operators? + $rangeStartCellRef = $output[count($output) - 2]['value']; + } + preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '$/miu', $rangeStartCellRef, $rangeStartMatches); if ($rangeStartMatches[2] !== $matches[2]) { return $this->raiseFormulaError('3D Range references are not yet supported'); } @@ -4461,21 +4469,21 @@ class Calculation // Process the operation in the appropriate manner switch ($token) { - // Comparison (Boolean) Operators - case '>': // Greater than - case '<': // Less than - case '>=': // Greater than or Equal to - case '<=': // Less than or Equal to - case '=': // Equality - case '<>': // Inequality + // Comparison (Boolean) Operators + case '>': // Greater than + case '<': // Less than + case '>=': // Greater than or Equal to + case '<=': // Less than or Equal to + case '=': // Equality + case '<>': // Inequality $result = $this->executeBinaryComparisonOperation($operand1, $operand2, (string) $token, $stack); if (isset($storeKey)) { $branchStore[$storeKey] = $result; } break; - // Binary Operators - case ':': // Range + // Binary Operators + case ':': // Range if (strpos($operand1Data['reference'], '!') !== false) { [$sheet1, $operand1Data['reference']] = Worksheet::extractSheetTitle($operand1Data['reference'], true); } else { diff --git a/tests/PhpSpreadsheetTests/Calculation/Engine/RangeTest.php b/tests/PhpSpreadsheetTests/Calculation/Engine/RangeTest.php index becf1d71..aa5bcccb 100644 --- a/tests/PhpSpreadsheetTests/Calculation/Engine/RangeTest.php +++ b/tests/PhpSpreadsheetTests/Calculation/Engine/RangeTest.php @@ -21,15 +21,7 @@ class RangeTest extends TestCase { $this->spreadSheet = new Spreadsheet(); $this->spreadSheet->getActiveSheet() - ->setCellValue('A1', 1) - ->setCellValue('B1', 2) - ->setCellValue('C1', 3) - ->setCellValue('A2', 4) - ->setCellValue('B2', 5) - ->setCellValue('C2', 6) - ->setCellValue('A3', 7) - ->setCellValue('B3', 8) - ->setCellValue('C3', 9); + ->fromArray(array_chunk(range(1, 240), 6), null, 'A1', true); } /** @@ -40,33 +32,39 @@ class RangeTest extends TestCase public function testRangeEvaluation(string $formula, $expectedResult): void { $workSheet = $this->spreadSheet->getActiveSheet(); - $workSheet->setCellValue('E1', $formula); + $workSheet->setCellValue('H1', $formula); - $actualRresult = $workSheet->getCell('E1')->getCalculatedValue(); + $actualRresult = $workSheet->getCell('H1')->getCalculatedValue(); self::assertSame($expectedResult, $actualRresult); } public function providerRangeEvaluation(): array { return[ - ['=SUM(A1:B3,A1:C2)', 48], - ['=COUNT(A1:B3,A1:C2)', 12], - ['=SUM(A1:B3 A1:C2)', 12], - ['=COUNT(A1:B3 A1:C2)', 4], - ['=SUM(A1:A3,C1:C3)', 30], - ['=COUNT(A1:A3,C1:C3)', 6], - ['=SUM(A1:A3 C1:C3)', Functions::null()], - ['=COUNT(A1:A3 C1:C3)', 0], - ['=SUM(A1:B2,B2:C3)', 40], - ['=COUNT(A1:B2,B2:C3)', 8], - ['=SUM(A1:B2 B2:C3)', 5], - ['=COUNT(A1:B2 B2:C3)', 1], - ['=SUM(A1:C1,A3:C3,B1:C3)', 63], - ['=COUNT(A1:C1,A3:C3,B1:C3)', 12], - ['=SUM(A1:C1,A3:C3 B1:C3)', 23], - ['=COUNT(A1:C1,A3:C3 B1:C3)', 5], - ['=SUM(Worksheet!A1:B3,Worksheet!A1:C2)', 48], - ['=SUM(Worksheet!A1:Worksheet!B3,Worksheet!A1:Worksheet!C2)', 48], + 'Sum with Simple Range' => ['=SUM(A1:C3)', 72], + 'Count with Simple Range' => ['=COUNT(A1:C3)', 9], + 'Sum with UNION #1' => ['=SUM(A1:B3,A1:C2)', 75], + 'Count with UNION #1' => ['=COUNT(A1:B3,A1:C2)', 12], + 'Sum with INTERSECTION #1' => ['=SUM(A1:B3 A1:C2)', 18], + 'Count with INTERSECTION #1' => ['=COUNT(A1:B3 A1:C2)', 4], + 'Sum with UNION #2' => ['=SUM(A1:A3,C1:C3)', 48], + 'Count with UNION #2' => ['=COUNT(A1:A3,C1:C3)', 6], + 'Sum with INTERSECTION #2 - No Intersect' => ['=SUM(A1:A3 C1:C3)', Functions::null()], + 'Count with INTERSECTION #2 - No Intersect' => ['=COUNT(A1:A3 C1:C3)', 0], + 'Sum with UNION #3' => ['=SUM(A1:B2,B2:C3)', 64], + 'Count with UNION #3' => ['=COUNT(A1:B2,B2:C3)', 8], + 'Sum with INTERSECTION #3 - Single Cell' => ['=SUM(A1:B2 B2:C3)', 8], + 'Count with INTERSECTION #3 - Single Cell' => ['=COUNT(A1:B2 B2:C3)', 1], + 'Sum with Triple UNION' => ['=SUM(A1:C1,A3:C3,B1:C3)', 99], + 'Count with Triple UNION' => ['=COUNT(A1:C1,A3:C3,B1:C3)', 12], + 'Sum with UNION and INTERSECTION' => ['=SUM(A1:C1,A3:C3 B1:C3)', 35], + 'Count with UNION and INTERSECTION' => ['=COUNT(A1:C1,A3:C3 B1:C3)', 5], + 'Sum with UNION with Worksheet Reference' => ['=SUM(Worksheet!A1:B3,Worksheet!A1:C2)', 75], + 'Sum with UNION with full Worksheet Reference' => ['=SUM(Worksheet!A1:Worksheet!B3,Worksheet!A1:Worksheet!C2)', 75], + 'Sum with Chained UNION #1' => ['=SUM(A3:B1:C2)', 72], + 'Count with Chained UNION #1' => ['=COUNT(A3:B1:C2)', 9], + 'Sum with Chained UNION #2' => ['=SUM(A5:C10:C20:F1)', 7260], + 'Count with Chained UNION#2' => ['=COUNT(A5:C10:C20:F1)', 120], ]; } @@ -97,16 +95,16 @@ class RangeTest extends TestCase public function providerNamedRangeEvaluation(): array { return[ - ['$A$1:$B$3', '$A$1:$C$2', '=SUM(GROUP1,GROUP2)', 48], + ['$A$1:$B$3', '$A$1:$C$2', '=SUM(GROUP1,GROUP2)', 75], ['$A$1:$B$3', '$A$1:$C$2', '=COUNT(GROUP1,GROUP2)', 12], - ['$A$1:$B$3', '$A$1:$C$2', '=SUM(GROUP1 GROUP2)', 12], + ['$A$1:$B$3', '$A$1:$C$2', '=SUM(GROUP1 GROUP2)', 18], ['$A$1:$B$3', '$A$1:$C$2', '=COUNT(GROUP1 GROUP2)', 4], - ['$A$1:$B$2', '$B$2:$C$3', '=SUM(GROUP1,GROUP2)', 40], + ['$A$1:$B$2', '$B$2:$C$3', '=SUM(GROUP1,GROUP2)', 64], ['$A$1:$B$2', '$B$2:$C$3', '=COUNT(GROUP1,GROUP2)', 8], - ['$A$1:$B$2', '$B$2:$C$3', '=SUM(GROUP1 GROUP2)', 5], + ['$A$1:$B$2', '$B$2:$C$3', '=SUM(GROUP1 GROUP2)', 8], ['$A$1:$B$2', '$B$2:$C$3', '=COUNT(GROUP1 GROUP2)', 1], - ['Worksheet!$A$1:$B$2', 'Worksheet!$B$2:$C$3', '=SUM(GROUP1,GROUP2)', 40], - ['Worksheet!$A$1:Worksheet!$B$2', 'Worksheet!$B$2:Worksheet!$C$3', '=SUM(GROUP1,GROUP2)', 40], + ['Worksheet!$A$1:$B$2', 'Worksheet!$B$2:$C$3', '=SUM(GROUP1,GROUP2)', 64], + ['Worksheet!$A$1:Worksheet!$B$2', 'Worksheet!$B$2:Worksheet!$C$3', '=SUM(GROUP1,GROUP2)', 64], ]; } @@ -132,9 +130,9 @@ class RangeTest extends TestCase public function providerUTF8NamedRangeEvaluation(): array { return[ - [['Γειά', 'σου', 'Κόσμε'], ['$A$1', '$B$1:$B$2', '$C$1:$C$3'], '=SUM(Γειά,σου,Κόσμε)', 26], + [['Γειά', 'σου', 'Κόσμε'], ['$A$1', '$B$1:$B$2', '$C$1:$C$3'], '=SUM(Γειά,σου,Κόσμε)', 38], [['Γειά', 'σου', 'Κόσμε'], ['$A$1', '$B$1:$B$2', '$C$1:$C$3'], '=COUNT(Γειά,σου,Κόσμε)', 6], - [['Здравствуй', 'мир'], ['$A$1:$A$3', '$C$1:$C$3'], '=SUM(Здравствуй,мир)', 30], + [['Здравствуй', 'мир'], ['$A$1:$A$3', '$C$1:$C$3'], '=SUM(Здравствуй,мир)', 48], ]; } From 40730c6023b2429dde4fcd7d4953044813998c3b Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Wed, 13 Apr 2022 14:28:15 +0200 Subject: [PATCH 22/52] Handle defined names with the range operator. It gets awkward when the defined name is for an actual range rather than for an individual named cell; because we need to manipulate the stack when that happens. The code is ugly, and this is a rather simplistic approach, but it works as long as the named range is a cell, a cell range, or even a "chained" range - it won't work if we have union or intersection operators in the defined range - but it does provide formula support that never existed before. --- CHANGELOG.md | 1 + .../Calculation/Calculation.php | 91 +++++++++++++------ .../Calculation/Engine/RangeTest.php | 34 ++++--- 3 files changed, 81 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3af4e647..a309672a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Fixed +- Support for "chained" ranges (e.g. `A5:C10:C20:F1`) in the Calculation Engine; and also support for using named ranges with the Range operator (e.g. `NamedRange1:NamedRange2`) [Issue #2730](https://github.com/PHPOffice/PhpSpreadsheet/issues/2730) [PR #2746](https://github.com/PHPOffice/PhpSpreadsheet/pull/2746) - Update Conditional Formatting ranges and rule conditions when inserting/deleting rows/columns [Issue #2678](https://github.com/PHPOffice/PhpSpreadsheet/issues/2678) [PR #2689](https://github.com/PHPOffice/PhpSpreadsheet/pull/2689) - Allow `INDIRECT()` to accept row/column ranges as well as cell ranges [PR #2687](https://github.com/PHPOffice/PhpSpreadsheet/pull/2687) - Fix bug when deleting cells with hyperlinks, where the hyperlink was then being "inherited" by whatever cell moved to that cell address. diff --git a/src/PhpSpreadsheet/Calculation/Calculation.php b/src/PhpSpreadsheet/Calculation/Calculation.php index c14f5252..fe310be4 100644 --- a/src/PhpSpreadsheet/Calculation/Calculation.php +++ b/src/PhpSpreadsheet/Calculation/Calculation.php @@ -4167,7 +4167,7 @@ class Calculation $outputItem = $stack->getStackItem('Cell Reference', $val, $val); $output[] = $outputItem; - } else { // it's a variable, constant, string, number or boolean + } else { // it's a variable, constant, string, number or boolean $localeConstant = false; $stackItemType = 'Value'; $stackItemReference = null; @@ -4176,39 +4176,62 @@ class Calculation $testPrevOp = $stack->last(1); if ($testPrevOp !== null && $testPrevOp['value'] === ':') { $stackItemType = 'Cell Reference'; - $startRowColRef = $output[count($output) - 1]['value']; - [$rangeWS1, $startRowColRef] = Worksheet::extractSheetTitle($startRowColRef, true); - $rangeSheetRef = $rangeWS1; - if ($rangeWS1 !== '') { - $rangeWS1 .= '!'; - } - $rangeSheetRef = trim($rangeSheetRef, "'"); - [$rangeWS2, $val] = Worksheet::extractSheetTitle($val, true); - if ($rangeWS2 !== '') { - $rangeWS2 .= '!'; + if ( + (preg_match('/^' . self::CALCULATION_REGEXP_DEFINEDNAME . '$/mui', $val) !== false) && + ($this->spreadsheet->getNamedRange($val) !== null) + ) { + $namedRange = $this->spreadsheet->getNamedRange($val); + if ($namedRange !== null) { + $stackItemType = 'Defined Name'; + $address = str_replace('$', '', $namedRange->getValue()); + $stackItemReference = $val; + if (strpos($address, ':') !== false) { + // We'll need to manipulate the stack for an actual named range rather than a named cell + $fromTo = explode(':', $address); + $to = array_pop($fromTo); + foreach ($fromTo as $from) { + $output[] = $stack->getStackItem($stackItemType, $from, $stackItemReference); + $output[] = $stack->getStackItem('Binary Operator', ':'); + } + $address = $to; + } + $val = $address; + } } else { - $rangeWS2 = $rangeWS1; - } + $startRowColRef = $output[count($output) - 1]['value']; + [$rangeWS1, $startRowColRef] = Worksheet::extractSheetTitle($startRowColRef, true); + $rangeSheetRef = $rangeWS1; + if ($rangeWS1 !== '') { + $rangeWS1 .= '!'; + } + $rangeSheetRef = trim($rangeSheetRef, "'"); + [$rangeWS2, $val] = Worksheet::extractSheetTitle($val, true); + if ($rangeWS2 !== '') { + $rangeWS2 .= '!'; + } else { + $rangeWS2 = $rangeWS1; + } - $refSheet = $pCellParent; - if ($pCellParent !== null && $rangeSheetRef !== '' && $rangeSheetRef !== $pCellParent->getTitle()) { - $refSheet = $pCellParent->getParent()->getSheetByName($rangeSheetRef); - } + $refSheet = $pCellParent; + if ($pCellParent !== null && $rangeSheetRef !== '' && $rangeSheetRef !== $pCellParent->getTitle()) { + $refSheet = $pCellParent->getParent()->getSheetByName($rangeSheetRef); + } - if (ctype_digit($val) && $val <= 1048576) { - // Row range - $stackItemType = 'Row Reference'; - /** @var int $valx */ - $valx = $val; - $endRowColRef = ($refSheet !== null) ? $refSheet->getHighestDataColumn($valx) : 'XFD'; // Max 16,384 columns for Excel2007 - $val = "{$rangeWS2}{$endRowColRef}{$val}"; - } elseif (ctype_alpha($val) && strlen($val) <= 3) { - // Column range - $stackItemType = 'Column Reference'; - $endRowColRef = ($refSheet !== null) ? $refSheet->getHighestDataRow($val) : 1048576; // Max 1,048,576 rows for Excel2007 - $val = "{$rangeWS2}{$val}{$endRowColRef}"; + if (ctype_digit($val) && $val <= 1048576) { + // Row range + $stackItemType = 'Row Reference'; + /** @var int $valx */ + $valx = $val; + $endRowColRef = ($refSheet !== null) ? $refSheet->getHighestDataColumn($valx) : 'XFD'; // Max 16,384 columns for Excel2007 + $val = "{$rangeWS2}{$endRowColRef}{$val}"; + } elseif (ctype_alpha($val) && strlen($val) <= 3) { + // Column range + $stackItemType = 'Column Reference'; + $endRowColRef = ($refSheet !== null) ? $refSheet->getHighestDataRow($val) : 1048576; // Max 1,048,576 rows for Excel2007 + $val = "{$rangeWS2}{$val}{$endRowColRef}"; + } + $stackItemReference = $val; } - $stackItemReference = $val; } elseif ($opCharacter == self::FORMULA_STRING_QUOTE) { // UnEscape any quotes within the string $val = self::wrapResult(str_replace('""', self::FORMULA_STRING_QUOTE, self::unwrapResult($val))); @@ -4484,6 +4507,14 @@ class Calculation break; // Binary Operators case ':': // Range + if ($operand1Data['type'] === 'Defined Name') { + if (preg_match('/$' . self::CALCULATION_REGEXP_DEFINEDNAME . '^/mui', $operand1Data['reference']) !== false) { + $definedName = $this->spreadsheet->getNamedRange($operand1Data['reference']); + if ($definedName !== null) { + $operand1Data['reference'] = $operand1Data['value'] = str_replace('$', '', $definedName->getValue()); + } + } + } if (strpos($operand1Data['reference'], '!') !== false) { [$sheet1, $operand1Data['reference']] = Worksheet::extractSheetTitle($operand1Data['reference'], true); } else { diff --git a/tests/PhpSpreadsheetTests/Calculation/Engine/RangeTest.php b/tests/PhpSpreadsheetTests/Calculation/Engine/RangeTest.php index aa5bcccb..caa7f396 100644 --- a/tests/PhpSpreadsheetTests/Calculation/Engine/RangeTest.php +++ b/tests/PhpSpreadsheetTests/Calculation/Engine/RangeTest.php @@ -80,31 +80,35 @@ class RangeTest extends TestCase /** * @dataProvider providerNamedRangeEvaluation */ - public function testNamedRangeEvaluation(string $group1, string $group2, string $formula, int $expectedResult): void + public function testNamedRangeEvaluation(array $ranges, string $formula, int $expectedResult): void { $workSheet = $this->spreadSheet->getActiveSheet(); - $this->spreadSheet->addNamedRange(new NamedRange('GROUP1', $workSheet, $group1)); - $this->spreadSheet->addNamedRange(new NamedRange('GROUP2', $workSheet, $group2)); + foreach ($ranges as $id => $range) { + $this->spreadSheet->addNamedRange(new NamedRange('GROUP' . ++$id, $workSheet, $range)); + } - $workSheet->setCellValue('E1', $formula); + $workSheet->setCellValue('H1', $formula); - $sumRresult = $workSheet->getCell('E1')->getCalculatedValue(); + $sumRresult = $workSheet->getCell('H1')->getCalculatedValue(); self::assertSame($expectedResult, $sumRresult); } public function providerNamedRangeEvaluation(): array { return[ - ['$A$1:$B$3', '$A$1:$C$2', '=SUM(GROUP1,GROUP2)', 75], - ['$A$1:$B$3', '$A$1:$C$2', '=COUNT(GROUP1,GROUP2)', 12], - ['$A$1:$B$3', '$A$1:$C$2', '=SUM(GROUP1 GROUP2)', 18], - ['$A$1:$B$3', '$A$1:$C$2', '=COUNT(GROUP1 GROUP2)', 4], - ['$A$1:$B$2', '$B$2:$C$3', '=SUM(GROUP1,GROUP2)', 64], - ['$A$1:$B$2', '$B$2:$C$3', '=COUNT(GROUP1,GROUP2)', 8], - ['$A$1:$B$2', '$B$2:$C$3', '=SUM(GROUP1 GROUP2)', 8], - ['$A$1:$B$2', '$B$2:$C$3', '=COUNT(GROUP1 GROUP2)', 1], - ['Worksheet!$A$1:$B$2', 'Worksheet!$B$2:$C$3', '=SUM(GROUP1,GROUP2)', 64], - ['Worksheet!$A$1:Worksheet!$B$2', 'Worksheet!$B$2:Worksheet!$C$3', '=SUM(GROUP1,GROUP2)', 64], + [['$A$1:$B$3', '$A$1:$C$2'], '=SUM(GROUP1,GROUP2)', 75], + [['$A$1:$B$3', '$A$1:$C$2'], '=COUNT(GROUP1,GROUP2)', 12], + [['$A$1:$B$3', '$A$1:$C$2'], '=SUM(GROUP1 GROUP2)', 18], + [['$A$1:$B$3', '$A$1:$C$2'], '=COUNT(GROUP1 GROUP2)', 4], + [['$A$1:$B$2', '$B$2:$C$3'], '=SUM(GROUP1,GROUP2)', 64], + [['$A$1:$B$2', '$B$2:$C$3'], '=COUNT(GROUP1,GROUP2)', 8], + [['$A$1:$B$2', '$B$2:$C$3'], '=SUM(GROUP1 GROUP2)', 8], + [['$A$1:$B$2', '$B$2:$C$3'], '=COUNT(GROUP1 GROUP2)', 1], + [['$A$5', '$C$10:$C$20', '$F$1'], '=SUM(GROUP1:GROUP2:GROUP3)', 7260], + [['$A$5:$A$7', '$C$20', '$F$1'], '=SUM(GROUP1:GROUP2:GROUP3)', 7260], + [['$A$5:$A$7', '$C$10:$C$20', '$F$1'], '=SUM(GROUP1:GROUP2:GROUP3)', 7260], + [['Worksheet!$A$1:$B$2', 'Worksheet!$B$2:$C$3'], '=SUM(GROUP1,GROUP2)', 64], + [['Worksheet!$A$1:Worksheet!$B$2', 'Worksheet!$B$2:Worksheet!$C$3'], '=SUM(GROUP1,GROUP2)', 64], ]; } From 19988626d4f6f47a0e9909f3a368f602a160dae8 Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Wed, 13 Apr 2022 18:17:29 +0200 Subject: [PATCH 23/52] Fix change log typo --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a096529..3912c622 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). - Implementation of the ISREF() Information function. - Added support for reading "formatted" numeric values from Csv files; although default behaviour of reading these values as strings is preserved. - (i.e a value of "12,345.67" can be read as numeric `1235.67`, not simply as a string `"12,345.67"`, if the `castFormattedNumberToNumeric()` setting is enabled. + (i.e a value of "12,345.67" can be read as numeric `12345.67`, not simply as a string `"12,345.67"`, if the `castFormattedNumberToNumeric()` setting is enabled. This functionality is locale-aware, using the server's locale settings to identify the thousands and decimal separators. From abaa1029198bfab674580088987772d14d8fac30 Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Wed, 13 Apr 2022 20:18:21 +0200 Subject: [PATCH 24/52] Resolve issue with boolean in branch pruning when array is expected --- src/PhpSpreadsheet/Calculation/Calculation.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PhpSpreadsheet/Calculation/Calculation.php b/src/PhpSpreadsheet/Calculation/Calculation.php index fe310be4..f30b1118 100644 --- a/src/PhpSpreadsheet/Calculation/Calculation.php +++ b/src/PhpSpreadsheet/Calculation/Calculation.php @@ -4413,7 +4413,7 @@ class Calculation true : (bool) Functions::flattenSingleValue($storeValue); if (is_array($storeValue)) { $wrappedItem = end($storeValue); - $storeValue = end($wrappedItem); + $storeValue = is_array($wrappedItem) ? end($wrappedItem) : $wrappedItem; } if ( @@ -4445,7 +4445,7 @@ class Calculation true : (bool) Functions::flattenSingleValue($storeValue); if (is_array($storeValue)) { $wrappedItem = end($storeValue); - $storeValue = end($wrappedItem); + $storeValue = is_array($wrappedItem) ? end($wrappedItem) : $wrappedItem; } if ( From 483ef53855f21da808c42aa9062863abbd6dc6ff Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Thu, 14 Apr 2022 21:21:08 +0200 Subject: [PATCH 25/52] Basic unit tests for formula parsing, in preparation for work on fully supporting the Union Operator, and for providing support for Structured References --- .../Calculation/Calculation.php | 18 +-- .../Calculation/ParseFormulaTest.php | 133 ++++++++++++++++++ 2 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Calculation/ParseFormulaTest.php diff --git a/src/PhpSpreadsheet/Calculation/Calculation.php b/src/PhpSpreadsheet/Calculation/Calculation.php index f30b1118..00621e41 100644 --- a/src/PhpSpreadsheet/Calculation/Calculation.php +++ b/src/PhpSpreadsheet/Calculation/Calculation.php @@ -108,7 +108,8 @@ class Calculation '+' => true, '-' => true, '*' => true, '/' => true, '^' => true, '&' => true, '%' => false, '~' => false, '>' => true, '<' => true, '=' => true, '>=' => true, - '<=' => true, '<>' => true, '|' => true, ':' => true, + '<=' => true, '<>' => true, '∩' => true, '∪' => true, + ':' => true, ]; /** @@ -120,7 +121,7 @@ class Calculation '+' => true, '-' => true, '*' => true, '/' => true, '^' => true, '&' => true, '>' => true, '<' => true, '=' => true, '>=' => true, '<=' => true, '<>' => true, - '|' => true, ':' => true, + '∩' => true, '∪' => true, ':' => true, ]; /** @@ -3872,7 +3873,7 @@ class Calculation '*' => 0, '/' => 0, // Multiplication and Division '+' => 0, '-' => 0, // Addition and Subtraction '&' => 0, // Concatenation - '|' => 0, ':' => 0, // Intersect and Range + '∪' => 0, '∩' => 0, ':' => 0, // Union, Intersect and Range '>' => 0, '<' => 0, '=' => 0, '>=' => 0, '<=' => 0, '<>' => 0, // Comparison ]; @@ -3884,8 +3885,9 @@ class Calculation // This list includes all valid operators, whether binary (including boolean) or unary (such as %) // Array key is the operator, the value is its precedence private static $operatorPrecedence = [ - ':' => 8, // Range - '|' => 7, // Intersect + ':' => 9, // Range + '∩' => 8, // Intersect + '∪' => 7, // Union '~' => 6, // Negation '%' => 5, // Percentage '^' => 4, // Exponentiation @@ -3957,7 +3959,7 @@ class Calculation ++$index; } elseif ($opCharacter == '+' && !$expectingOperator) { // Positive (unary plus rather than binary operator plus) can be discarded? ++$index; // Drop the redundant plus symbol - } elseif ((($opCharacter == '~') || ($opCharacter == '|')) && (!$isOperandOrFunction)) { // We have to explicitly deny a tilde or pipe, because they are legal + } elseif ((($opCharacter == '~') || ($opCharacter == '∩') || ($opCharacter == '∪')) && (!$isOperandOrFunction)) { // We have to explicitly deny a tilde, union or intersect because they are legal return $this->raiseFormulaError("Formula Error: Illegal character '~'"); // on the stack but not in the input expression } elseif ((isset(self::$operators[$opCharacter]) || $isOperandOrFunction) && $expectingOperator) { // Are we putting an operator on the stack? while ( @@ -4338,7 +4340,7 @@ class Calculation ) { $output[] = $stack->pop(); // Swap operands and higher precedence operators from the stack to the output } - $stack->push('Binary Operator', '|'); // Put an Intersect Operator on the stack + $stack->push('Binary Operator', '∩'); // Put an Intersect Operator on the stack $expectingOperator = false; } } @@ -4638,7 +4640,7 @@ class Calculation } break; - case '|': // Intersect + case '∩': // Intersect $rowIntersect = array_intersect_key($operand1, $operand2); $cellIntersect = $oCol = $oRow = []; foreach (array_keys($rowIntersect) as $row) { diff --git a/tests/PhpSpreadsheetTests/Calculation/ParseFormulaTest.php b/tests/PhpSpreadsheetTests/Calculation/ParseFormulaTest.php new file mode 100644 index 00000000..a0c627ab --- /dev/null +++ b/tests/PhpSpreadsheetTests/Calculation/ParseFormulaTest.php @@ -0,0 +1,133 @@ +parseFormula($formula); + self::assertSame($expectedStack, $stack); + } + + public function providerBinaryOperations(): array + { + return [ + 'Unary negative with Value' => [ + [ + ['type' => 'Value', 'value' => 3, 'reference' => null], + ['type' => 'Unary Operator', 'value' => '~', 'reference' => null], + ], + '=-3', + ], + 'Unary negative percentage with Value' => [ + [ + ['type' => 'Value', 'value' => 3, 'reference' => null], + ['type' => 'Unary Operator', 'value' => '%', 'reference' => null], + ['type' => 'Unary Operator', 'value' => '~', 'reference' => null], + ], + '=-3%', + ], + 'Binary minus with Values' => [ + [ + ['type' => 'Value', 'value' => 3, 'reference' => null], + ['type' => 'Value', 'value' => 4, 'reference' => null], + ['type' => 'Binary Operator', 'value' => '-', 'reference' => null], + ], + '=3-4', + ], + 'Unary negative with Cell Reference' => [ + [ + ['type' => 'Cell Reference', 'value' => 'A1', 'reference' => 'A1'], + ['type' => 'Unary Operator', 'value' => '~', 'reference' => null], + ], + '=-A1', + ], + 'Unary negative with FQ Cell Reference' => [ + [ + ['type' => 'Cell Reference', 'value' => "'Sheet 1'!A1", 'reference' => "'Sheet 1'!A1"], + ['type' => 'Unary Operator', 'value' => '~', 'reference' => null], + ], + "=-'Sheet 1'!A1", + ], + 'Unary negative percentage with Cell Reference' => [ + [ + ['type' => 'Cell Reference', 'value' => 'A1', 'reference' => 'A1'], + ['type' => 'Unary Operator', 'value' => '%', 'reference' => null], + ['type' => 'Unary Operator', 'value' => '~', 'reference' => null], + ], + '=-A1%', + ], + 'Unary negative with Defined Name' => [ + [ + ['type' => 'Defined Name', 'value' => 'DEFINED_NAME', 'reference' => 'DEFINED_NAME'], + ['type' => 'Unary Operator', 'value' => '~', 'reference' => null], + ], + '=-DEFINED_NAME', + ], + 'Unary negative percentage with Defined Name' => [ + [ + ['type' => 'Defined Name', 'value' => 'DEFINED_NAME', 'reference' => 'DEFINED_NAME'], + ['type' => 'Unary Operator', 'value' => '%', 'reference' => null], + ['type' => 'Unary Operator', 'value' => '~', 'reference' => null], + ], + '=-DEFINED_NAME%', + ], + 'Cell Range' => [ + [ + ['type' => 'Cell Reference', 'value' => 'A1', 'reference' => 'A1'], + ['type' => 'Cell Reference', 'value' => 'C3', 'reference' => 'C3'], + ['type' => 'Binary Operator', 'value' => ':', 'reference' => null], + ], + '=A1:C3', + ], + 'Cell Range Intersection' => [ + [ + ['type' => 'Cell Reference', 'value' => 'A1', 'reference' => 'A1'], + ['type' => 'Cell Reference', 'value' => 'C3', 'reference' => 'C3'], + ['type' => 'Binary Operator', 'value' => ':', 'reference' => null], + ['type' => 'Cell Reference', 'value' => 'B2', 'reference' => 'B2'], + ['type' => 'Cell Reference', 'value' => 'D4', 'reference' => 'D4'], + ['type' => 'Binary Operator', 'value' => ':', 'reference' => null], + ['type' => 'Binary Operator', 'value' => '∩', 'reference' => null], + ], + '=A1:C3 B2:D4', + ], + 'Named Range Intersection' => [ + [ + ['type' => 'Defined Name', 'value' => 'DEFINED_NAME_1', 'reference' => 'DEFINED_NAME_1'], + ['type' => 'Defined Name', 'value' => 'DEFINED_NAME_2', 'reference' => 'DEFINED_NAME_2'], + ['type' => 'Binary Operator', 'value' => '∩', 'reference' => null], + ], + '=DEFINED_NAME_1 DEFINED_NAME_2', + ], + // 'Cell Range Union' => [ + // [ + // ['type' => 'Cell Reference', 'value' => 'A1', 'reference' => 'A1'], + // ['type' => 'Cell Reference', 'value' => 'C3', 'reference' => 'C3'], + // ['type' => 'Binary Operator', 'value' => ':', 'reference' => null], + // ['type' => 'Cell Reference', 'value' => 'B2', 'reference' => 'B2'], + // ['type' => 'Cell Reference', 'value' => 'D4', 'reference' => 'D4'], + // ['type' => 'Binary Operator', 'value' => ':', 'reference' => null], + // ['type' => 'Binary Operator', 'value' => '∪', 'reference' => null], + // ], + // '=A1:C3,B2:D4', + // ], + // 'Named Range Union' => [ + // [ + // ['type' => 'Defined Name', 'value' => 'DEFINED_NAME_1', 'reference' => 'DEFINED_NAME_1'], + // ['type' => 'Defined Name', 'value' => 'DEFINED_NAME_2', 'reference' => 'DEFINED_NAME_2'], + // ['type' => 'Binary Operator', 'value' => '∪', 'reference' => null], + // ], + // '=DEFINED_NAME_1,DEFINED_NAME_2', + // ], + ]; + } +} From 716964eeecb1ad61dad749f886f2003393e4d1b3 Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Fri, 15 Apr 2022 14:51:31 +0200 Subject: [PATCH 26/52] Resolve Calculation Engine bug with row and column ranges being identified as named ranges, adding overhead with the additional validation to process that named range --- .../Calculation/Calculation.php | 3 + .../Calculation/ParseFormulaTest.php | 98 ++++++++++++++++++- 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/src/PhpSpreadsheet/Calculation/Calculation.php b/src/PhpSpreadsheet/Calculation/Calculation.php index 00621e41..26ef7946 100644 --- a/src/PhpSpreadsheet/Calculation/Calculation.php +++ b/src/PhpSpreadsheet/Calculation/Calculation.php @@ -4178,7 +4178,10 @@ class Calculation $testPrevOp = $stack->last(1); if ($testPrevOp !== null && $testPrevOp['value'] === ':') { $stackItemType = 'Cell Reference'; + if ( + !is_numeric($val) && + ((ctype_alpha($val) === false || strlen($val) > 3)) && (preg_match('/^' . self::CALCULATION_REGEXP_DEFINEDNAME . '$/mui', $val) !== false) && ($this->spreadsheet->getNamedRange($val) !== null) ) { diff --git a/tests/PhpSpreadsheetTests/Calculation/ParseFormulaTest.php b/tests/PhpSpreadsheetTests/Calculation/ParseFormulaTest.php index a0c627ab..92330b7d 100644 --- a/tests/PhpSpreadsheetTests/Calculation/ParseFormulaTest.php +++ b/tests/PhpSpreadsheetTests/Calculation/ParseFormulaTest.php @@ -3,6 +3,8 @@ namespace PhpOffice\PhpSpreadsheetTests\Calculation; use PhpOffice\PhpSpreadsheet\Calculation\Calculation; +use PhpOffice\PhpSpreadsheet\NamedRange; +use PhpOffice\PhpSpreadsheet\Spreadsheet; use PHPUnit\Framework\TestCase; class ParseFormulaTest extends TestCase @@ -12,7 +14,11 @@ class ParseFormulaTest extends TestCase */ public function testParseOperations(array $expectedStack, string $formula): void { - $parser = Calculation::getInstance(); + $spreadsheet = new Spreadsheet(); + $spreadsheet->addNamedRange(new NamedRange('GROUP1', $spreadsheet->getActiveSheet(), 'B2:D4')); + $spreadsheet->addNamedRange(new NamedRange('GROUP2', $spreadsheet->getActiveSheet(), 'D4:F6')); + + $parser = Calculation::getInstance($spreadsheet); $stack = $parser->parseFormula($formula); self::assertSame($expectedStack, $stack); } @@ -80,6 +86,36 @@ class ParseFormulaTest extends TestCase ], '=-DEFINED_NAME%', ], + 'Integer Numbers with Operator' => [ + [ + ['type' => 'Value', 'value' => 2, 'reference' => null], + ['type' => 'Value', 'value' => 3, 'reference' => null], + ['type' => 'Binary Operator', 'value' => '*', 'reference' => null], + ], + '=2*3', + ], + 'Float Numbers with Operator' => [ + [ + ['type' => 'Value', 'value' => 2.5, 'reference' => null], + ['type' => 'Value', 'value' => 3.5, 'reference' => null], + ['type' => 'Binary Operator', 'value' => '*', 'reference' => null], + ], + '=2.5*3.5', + ], + 'Strings with Operator' => [ + [ + ['type' => 'Value', 'value' => '"HELLO"', 'reference' => null], + ['type' => 'Value', 'value' => '"WORLD"', 'reference' => null], + ['type' => 'Binary Operator', 'value' => '&', 'reference' => null], + ], + '="HELLO"&"WORLD"', + ], + 'Error' => [ + [ + ['type' => 'Value', 'value' => '#DIV0!', 'reference' => null], + ], + '=#DIV0!', + ], 'Cell Range' => [ [ ['type' => 'Cell Reference', 'value' => 'A1', 'reference' => 'A1'], @@ -88,6 +124,16 @@ class ParseFormulaTest extends TestCase ], '=A1:C3', ], + 'Chained Cell Range' => [ + [ + ['type' => 'Cell Reference', 'value' => 'A1', 'reference' => 'A1'], + ['type' => 'Cell Reference', 'value' => 'C3', 'reference' => 'C3'], + ['type' => 'Binary Operator', 'value' => ':', 'reference' => null], + ['type' => 'Cell Reference', 'value' => 'E5', 'reference' => 'E5'], + ['type' => 'Binary Operator', 'value' => ':', 'reference' => null], + ], + '=A1:C3:E5', + ], 'Cell Range Intersection' => [ [ ['type' => 'Cell Reference', 'value' => 'A1', 'reference' => 'A1'], @@ -100,6 +146,40 @@ class ParseFormulaTest extends TestCase ], '=A1:C3 B2:D4', ], + 'Row Range' => [ + [ + ['type' => 'Row Reference', 'value' => 'A2', 'reference' => 'A2'], + ['type' => 'Row Reference', 'value' => 'XFD3', 'reference' => 'XFD3'], + ['type' => 'Binary Operator', 'value' => ':', 'reference' => null], + ], + '=2:3', + ], + 'Column Range' => [ + [ + ['type' => 'Column Reference', 'value' => 'B1', 'reference' => 'B1'], + ['type' => 'Column Reference', 'value' => 'C1048576', 'reference' => 'C1048576'], + ['type' => 'Binary Operator', 'value' => ':', 'reference' => null], + ], + '=B:C', + ], + 'Range with Defined Names' => [ + [ + ['type' => 'Defined Name', 'value' => 'GROUP1', 'reference' => 'GROUP1'], + ['type' => 'Defined Name', 'value' => 'D4', 'reference' => 'GROUP2'], + ['type' => 'Binary Operator', 'value' => ':', 'reference' => null], + ['type' => 'Defined Name', 'value' => 'F6', 'reference' => 'GROUP2'], + ['type' => 'Binary Operator', 'value' => ':', 'reference' => null], + ], + '=GROUP1:GROUP2', + ], + 'Named Range with Binary Operator' => [ + [ + ['type' => 'Defined Name', 'value' => 'DEFINED_NAME_1', 'reference' => 'DEFINED_NAME_1'], + ['type' => 'Defined Name', 'value' => 'DEFINED_NAME_2', 'reference' => 'DEFINED_NAME_2'], + ['type' => 'Binary Operator', 'value' => '/', 'reference' => null], + ], + '=DEFINED_NAME_1/DEFINED_NAME_2', + ], 'Named Range Intersection' => [ [ ['type' => 'Defined Name', 'value' => 'DEFINED_NAME_1', 'reference' => 'DEFINED_NAME_1'], @@ -108,6 +188,22 @@ class ParseFormulaTest extends TestCase ], '=DEFINED_NAME_1 DEFINED_NAME_2', ], + // 'Structured Reference Arithmetic' => [ + // [ + // ['type' => 'Structured Reference', 'value' => '[@Quantity]', 'reference' => null], + // ['type' => 'Structured Reference', 'value' => '[@[Unit Price]]', 'reference' => null], + // ['type' => 'Binary Operator', 'value' => '*', 'reference' => null], + // ], + // '=[@Quantity]*[@[Unit Price]]', + // ], + // 'Structured Reference Intersection' => [ + // [ + // ['type' => 'Structured Reference', 'value' => 'DeptSales[[Sales Person]:[Sales Amount]]', 'reference' => null], + // ['type' => 'Structured Reference', 'value' => 'DeptSales[[Region]:[% Commission]]', 'reference' => null], + // ['type' => 'Binary Operator', 'value' => '∩', 'reference' => null], + // ], + // '=DeptSales[[Sales Person]:[Sales Amount]] DeptSales[[Region]:[% Commission]]', + // ], // 'Cell Range Union' => [ // [ // ['type' => 'Cell Reference', 'value' => 'A1', 'reference' => 'A1'], From 7a2f5c4ccc3cd69234179488d0e1dd9e3f6ed7bc Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Sat, 16 Apr 2022 19:34:41 +0200 Subject: [PATCH 27/52] Ods Writer support for setting column width/row height (including Autosizing) --- CHANGELOG.md | 1 + phpstan-baseline.neon | 5 -- src/PhpSpreadsheet/Writer/Ods/Cell/Style.php | 62 +++++++++++++++++++ src/PhpSpreadsheet/Writer/Ods/Content.php | 51 ++++++++++++--- .../Writer/PreCalcTest.php | 2 +- tests/data/Writer/Ods/content-empty.xml | 1 - tests/data/Writer/Ods/content-with-data.xml | 2 - 7 files changed, 105 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3912c622..ceda96f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Added +- Ods Writer support for setting column width/row height (including the use of AutoSize) [Issue #2346](https://github.com/PHPOffice/PhpSpreadsheet/issues/2346) [PR #2753](https://github.com/PHPOffice/PhpSpreadsheet/pull/2753) - Introduced CellAddress, CellRange, RowRange and ColumnRange value objects that can be used as an alternative to a string value (e.g. `'C5'`, `'B2:D4'`, `'2:2'` or `'B:C'`) in appropriate contexts. - Implementation of the FILTER(), SORT(), SORTBY() and UNIQUE() Lookup/Reference (array) functions. - Implementation of the ISREF() Information function. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 12329a72..c6d3dda9 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -4675,11 +4675,6 @@ parameters: count: 1 path: src/PhpSpreadsheet/Writer/Ods/Content.php - - - message: "#^Parameter \\#2 \\$value of method XMLWriter\\:\\:writeAttribute\\(\\) expects string, int given\\.$#" - count: 4 - path: src/PhpSpreadsheet/Writer/Ods/Content.php - - message: "#^Parameter \\#2 \\$value of method XMLWriter\\:\\:writeAttribute\\(\\) expects string, int\\<2, max\\> given\\.$#" count: 3 diff --git a/src/PhpSpreadsheet/Writer/Ods/Cell/Style.php b/src/PhpSpreadsheet/Writer/Ods/Cell/Style.php index f8aae20c..a3629bd6 100644 --- a/src/PhpSpreadsheet/Writer/Ods/Cell/Style.php +++ b/src/PhpSpreadsheet/Writer/Ods/Cell/Style.php @@ -2,15 +2,20 @@ namespace PhpOffice\PhpSpreadsheet\Writer\Ods\Cell; +use PhpOffice\PhpSpreadsheet\Helper\Dimension; use PhpOffice\PhpSpreadsheet\Shared\XMLWriter; use PhpOffice\PhpSpreadsheet\Style\Alignment; use PhpOffice\PhpSpreadsheet\Style\Fill; use PhpOffice\PhpSpreadsheet\Style\Font; use PhpOffice\PhpSpreadsheet\Style\Style as CellStyle; +use PhpOffice\PhpSpreadsheet\Worksheet\ColumnDimension; +use PhpOffice\PhpSpreadsheet\Worksheet\RowDimension; class Style { public const CELL_STYLE_PREFIX = 'ce'; + public const COLUMN_STYLE_PREFIX = 'co'; + public const ROW_STYLE_PREFIX = 'ro'; private $writer; @@ -159,6 +164,63 @@ class Style $this->writer->endElement(); // Close style:text-properties } + protected function writeColumnProperties(ColumnDimension $columnDimension): void + { + $this->writer->startElement('style:table-column-properties'); + $this->writer->writeAttribute( + 'style:column-width', + round($columnDimension->getWidth(Dimension::UOM_CENTIMETERS), 3) . 'cm' + ); + $this->writer->writeAttribute('fo:break-before', 'auto'); + + // End + $this->writer->endElement(); // Close style:table-column-properties + } + + public function writeColumnStyles(ColumnDimension $columnDimension, int $sheetId): void + { + $this->writer->startElement('style:style'); + $this->writer->writeAttribute('style:family', 'table-column'); + $this->writer->writeAttribute( + 'style:name', + sprintf('%s_%d_%d', self::COLUMN_STYLE_PREFIX, $sheetId, $columnDimension->getColumnNumeric()) + ); + + $this->writeColumnProperties($columnDimension); + + // End + $this->writer->endElement(); // Close style:style + } + + protected function writeRowProperties(RowDimension $rowDimension): void + { + $this->writer->startElement('style:table-row-properties'); + $this->writer->writeAttribute( + 'style:row-height', + round($rowDimension->getRowHeight(Dimension::UOM_CENTIMETERS), 3) . 'cm' + ); + $this->writer->writeAttribute('style:use-optimal-row-height', 'true'); + $this->writer->writeAttribute('fo:break-before', 'auto'); + + // End + $this->writer->endElement(); // Close style:table-row-properties + } + + public function writeRowStyles(RowDimension $rowDimension, int $sheetId): void + { + $this->writer->startElement('style:style'); + $this->writer->writeAttribute('style:family', 'table-row'); + $this->writer->writeAttribute( + 'style:name', + sprintf('%s_%d_%d', self::ROW_STYLE_PREFIX, $sheetId, $rowDimension->getRowIndex()) + ); + + $this->writeRowProperties($rowDimension); + + // End + $this->writer->endElement(); // Close style:style + } + public function write(CellStyle $style): void { $this->writer->startElement('style:style'); diff --git a/src/PhpSpreadsheet/Writer/Ods/Content.php b/src/PhpSpreadsheet/Writer/Ods/Content.php index a589e549..2cb31b36 100644 --- a/src/PhpSpreadsheet/Writer/Ods/Content.php +++ b/src/PhpSpreadsheet/Writer/Ods/Content.php @@ -119,14 +119,21 @@ class Content extends WriterPart { $spreadsheet = $this->getParentWriter()->getSpreadsheet(); /** @var Spreadsheet $spreadsheet */ $sheetCount = $spreadsheet->getSheetCount(); - for ($i = 0; $i < $sheetCount; ++$i) { + for ($sheetIndex = 0; $sheetIndex < $sheetCount; ++$sheetIndex) { $objWriter->startElement('table:table'); - $objWriter->writeAttribute('table:name', $spreadsheet->getSheet($i)->getTitle()); + $objWriter->writeAttribute('table:name', $spreadsheet->getSheet($sheetIndex)->getTitle()); $objWriter->writeElement('office:forms'); - $objWriter->startElement('table:table-column'); - $objWriter->writeAttribute('table:number-columns-repeated', self::NUMBER_COLS_REPEATED_MAX); - $objWriter->endElement(); - $this->writeRows($objWriter, $spreadsheet->getSheet($i)); + foreach ($spreadsheet->getSheet($sheetIndex)->getColumnDimensions() as $columnDimension) { + $objWriter->startElement('table:table-column'); + $objWriter->writeAttribute( + 'table:style-name', + sprintf('%s_%d_%d', Style::COLUMN_STYLE_PREFIX, $sheetIndex, $columnDimension->getColumnNumeric()) + ); + $objWriter->writeAttribute('table:default-cell-style-name', 'ce0'); +// $objWriter->writeAttribute('table:number-columns-repeated', self::NUMBER_COLS_REPEATED_MAX); + $objWriter->endElement(); + } + $this->writeRows($objWriter, $spreadsheet->getSheet($sheetIndex), $sheetIndex); $objWriter->endElement(); } } @@ -134,7 +141,7 @@ class Content extends WriterPart /** * Write rows of the specified sheet. */ - private function writeRows(XMLWriter $objWriter, Worksheet $sheet): void + private function writeRows(XMLWriter $objWriter, Worksheet $sheet, int $sheetIndex): void { $numberRowsRepeated = self::NUMBER_ROWS_REPEATED_MAX; $span_row = 0; @@ -148,8 +155,14 @@ class Content extends WriterPart if ($span_row > 1) { $objWriter->writeAttribute('table:number-rows-repeated', $span_row); } + if ($sheet->getRowDimension($row->getRowIndex())->getRowHeight() > 0) { + $objWriter->writeAttribute( + 'table:style_name', + sprintf('%s_%d_%d', Style::ROW_STYLE_PREFIX, $sheetIndex, $row->getRowIndex()) + ); + } $objWriter->startElement('table:table-cell'); - $objWriter->writeAttribute('table:number-columns-repeated', self::NUMBER_COLS_REPEATED_MAX); + $objWriter->writeAttribute('table:number-columns-repeated', (string) self::NUMBER_COLS_REPEATED_MAX); $objWriter->endElement(); $objWriter->endElement(); $span_row = 0; @@ -275,6 +288,24 @@ class Content extends WriterPart private function writeXfStyles(XMLWriter $writer, Spreadsheet $spreadsheet): void { $styleWriter = new Style($writer); + + $sheetCount = $spreadsheet->getSheetCount(); + for ($i = 0; $i < $sheetCount; ++$i) { + $worksheet = $spreadsheet->getSheet($i); + $worksheet->calculateColumnWidths(); + foreach ($worksheet->getColumnDimensions() as $columnDimension) { + $styleWriter->writeColumnStyles($columnDimension, $i); + } + } + for ($i = 0; $i < $sheetCount; ++$i) { + $worksheet = $spreadsheet->getSheet($i); + foreach ($worksheet->getRowDimensions() as $rowDimension) { + if ($rowDimension->getRowHeight() > 0.0) { + $styleWriter->writeRowStyles($rowDimension, $i); + } + } + } + foreach ($spreadsheet->getCellXfCollection() as $style) { $styleWriter->write($style); } @@ -296,7 +327,7 @@ class Content extends WriterPart $columnSpan = Coordinate::columnIndexFromString($end[0]) - Coordinate::columnIndexFromString($start[0]) + 1; $rowSpan = ((int) $end[1]) - ((int) $start[1]) + 1; - $objWriter->writeAttribute('table:number-columns-spanned', $columnSpan); - $objWriter->writeAttribute('table:number-rows-spanned', $rowSpan); + $objWriter->writeAttribute('table:number-columns-spanned', (string) $columnSpan); + $objWriter->writeAttribute('table:number-rows-spanned', (string) $rowSpan); } } diff --git a/tests/PhpSpreadsheetTests/Writer/PreCalcTest.php b/tests/PhpSpreadsheetTests/Writer/PreCalcTest.php index 2db372c4..73374e21 100644 --- a/tests/PhpSpreadsheetTests/Writer/PreCalcTest.php +++ b/tests/PhpSpreadsheetTests/Writer/PreCalcTest.php @@ -64,7 +64,7 @@ class PreCalcTest extends AbstractFunctional } } - private const AUTOSIZE_TYPES = ['Xlsx', 'Xls', 'Html']; + private const AUTOSIZE_TYPES = ['Xlsx', 'Xls', 'Html', 'Ods']; private static function verifyA3B2(Calculation $calculation, string $title, ?bool $preCalc, string $type): void { diff --git a/tests/data/Writer/Ods/content-empty.xml b/tests/data/Writer/Ods/content-empty.xml index c9620060..867cfa3e 100644 --- a/tests/data/Writer/Ods/content-empty.xml +++ b/tests/data/Writer/Ods/content-empty.xml @@ -14,7 +14,6 @@ - diff --git a/tests/data/Writer/Ods/content-with-data.xml b/tests/data/Writer/Ods/content-with-data.xml index a707d197..fff47f65 100644 --- a/tests/data/Writer/Ods/content-with-data.xml +++ b/tests/data/Writer/Ods/content-with-data.xml @@ -64,7 +64,6 @@ - 1 @@ -110,7 +109,6 @@ - 2 From 8889ecf0446f2e996de55a5247a32d58462ea1fd Mon Sep 17 00:00:00 2001 From: aswinkumar863 Date: Sun, 17 Apr 2022 17:41:49 +0530 Subject: [PATCH 28/52] Support UTF-8 Table names Added support for UTF-8 Table names (including combined character) --- src/PhpSpreadsheet/Worksheet/Table.php | 4 ++-- tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/PhpSpreadsheet/Worksheet/Table.php b/src/PhpSpreadsheet/Worksheet/Table.php index 04a34e6f..cecd224a 100644 --- a/src/PhpSpreadsheet/Worksheet/Table.php +++ b/src/PhpSpreadsheet/Worksheet/Table.php @@ -103,10 +103,10 @@ class Table ) { throw new PhpSpreadsheetException('The table name can\'t be the same as a cell reference'); } - if (!preg_match('/^[A-Z_\\\\]/i', $name)) { + 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('/^[A-Z_\\\\][A-Z0-9\._]+$/i', $name)) { + if (!preg_match('/^[\p{L}_\\\\][\p{L}\p{M}0-9\._]+$/iu', $name)) { throw new PhpSpreadsheetException('The table name contains invalid characters'); } diff --git a/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php b/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php index 1b1edb83..c4e5f579 100644 --- a/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php +++ b/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php @@ -42,6 +42,8 @@ class TableTest extends SetupTeardown ['_table_2', '_table_2'], ['\table_3', '\table_3'], [" Table_4 \n", 'Table_4'], + ['table.5', 'table.5'], + ['தமிழ்', 'தமிழ்'], // UTF-8 letters with combined character ]; } @@ -72,6 +74,7 @@ class TableTest extends SetupTeardown ['R11C11'], ['123'], ['=Table'], + ['ிக'], // starting with UTF-8 combined character [bin2hex(random_bytes(255))], // random string with length greater than 255 ]; } From 3a6ebc0ce6de43c6f37c6331177767e12245b4e5 Mon Sep 17 00:00:00 2001 From: aswinkumar863 Date: Sun, 17 Apr 2022 17:42:35 +0530 Subject: [PATCH 29/52] Fixed coding standard with strict comparisons --- src/PhpSpreadsheet/Worksheet/Table.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/PhpSpreadsheet/Worksheet/Table.php b/src/PhpSpreadsheet/Worksheet/Table.php index cecd224a..cfb8edfd 100644 --- a/src/PhpSpreadsheet/Worksheet/Table.php +++ b/src/PhpSpreadsheet/Worksheet/Table.php @@ -90,7 +90,7 @@ class Table { $name = trim($name); - if (strlen($name) == 1 && in_array($name, ['C', 'c', 'R', 'r'])) { + if (strlen($name) === 1 && in_array($name, ['C', 'c', 'R', 'r'])) { throw new PhpSpreadsheetException('The table name is invalid'); } if (strlen($name) > 255) { @@ -238,12 +238,12 @@ class Table */ public function setWorksheet(?Worksheet $worksheet = null) { - if ($this->name != '' && $worksheet != null) { + if ($this->name !== '' && $worksheet !== null) { $spreadsheet = $worksheet->getParent(); foreach ($spreadsheet->getWorksheetIterator() as $sheet) { foreach ($sheet->getTableCollection() as $table) { - if ($table->getName() == $this->name) { + if ($table->getName() === $this->name) { throw new PhpSpreadsheetException("Workbook already contains a table named '{$this->name}'"); } } @@ -446,7 +446,7 @@ class Table } else { $this->{$key} = clone $value; } - } elseif ((is_array($value)) && ($key == 'columns')) { + } elseif ((is_array($value)) && ($key === 'columns')) { // The columns array of \PhpOffice\PhpSpreadsheet\Worksheet\Worksheet\Table objects $this->{$key} = []; foreach ($value as $k => $v) { From 3c4a51acb5b50a2d04817db03d063d34b8a5302f Mon Sep 17 00:00:00 2001 From: aswinkumar863 Date: Sun, 17 Apr 2022 17:43:32 +0530 Subject: [PATCH 30/52] Minor refactoring work testColumnInRange method renamed to isColumnInRange --- src/PhpSpreadsheet/Worksheet/Table.php | 10 +++++----- src/PhpSpreadsheet/Worksheet/Table/Column.php | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/PhpSpreadsheet/Worksheet/Table.php b/src/PhpSpreadsheet/Worksheet/Table.php index cfb8edfd..ee664aa6 100644 --- a/src/PhpSpreadsheet/Worksheet/Table.php +++ b/src/PhpSpreadsheet/Worksheet/Table.php @@ -272,7 +272,7 @@ class Table * * @return int The column offset within the table range */ - public function testColumnInRange($column) + public function isColumnInRange($column) { if (empty($this->range)) { throw new PhpSpreadsheetException('No table range is defined.'); @@ -296,7 +296,7 @@ class Table */ public function getColumnOffset($column) { - return $this->testColumnInRange($column); + return $this->isColumnInRange($column); } /** @@ -308,7 +308,7 @@ class Table */ public function getColumn($column) { - $this->testColumnInRange($column); + $this->isColumnInRange($column); if (!isset($this->columns[$column])) { $this->columns[$column] = new Table\Column($column, $this); @@ -349,7 +349,7 @@ class Table } else { throw new PhpSpreadsheetException('Column is not within the table range.'); } - $this->testColumnInRange($column); + $this->isColumnInRange($column); if (is_string($columnObjectOrString)) { $this->columns[$columnObjectOrString] = new Table\Column($columnObjectOrString, $this); @@ -371,7 +371,7 @@ class Table */ public function clearColumn($column) { - $this->testColumnInRange($column); + $this->isColumnInRange($column); if (isset($this->columns[$column])) { unset($this->columns[$column]); diff --git a/src/PhpSpreadsheet/Worksheet/Table/Column.php b/src/PhpSpreadsheet/Worksheet/Table/Column.php index 06284c67..385ba171 100644 --- a/src/PhpSpreadsheet/Worksheet/Table/Column.php +++ b/src/PhpSpreadsheet/Worksheet/Table/Column.php @@ -89,7 +89,7 @@ class Column // Uppercase coordinate $column = strtoupper($column); if ($this->table !== null) { - $this->table->testColumnInRange($column); + $this->table->isColumnInRange($column); } $this->columnIndex = $column; From ea3263650bb10d8646df91a847ac536376de6e46 Mon Sep 17 00:00:00 2001 From: aswinkumar863 Date: Sun, 17 Apr 2022 17:45:17 +0530 Subject: [PATCH 31/52] Minimum Table range validation Range must be at least 1 column and 2 rows --- src/PhpSpreadsheet/Worksheet/Table.php | 5 +++++ .../Worksheet/Table/TableTest.php | 20 +++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/PhpSpreadsheet/Worksheet/Table.php b/src/PhpSpreadsheet/Worksheet/Table.php index ee664aa6..0198dd2e 100644 --- a/src/PhpSpreadsheet/Worksheet/Table.php +++ b/src/PhpSpreadsheet/Worksheet/Table.php @@ -190,6 +190,11 @@ class Table 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); diff --git a/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php b/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php index c4e5f579..9602f27d 100644 --- a/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php +++ b/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php @@ -173,14 +173,26 @@ class TableTest extends SetupTeardown self::assertEquals($expectedResult, $result); } - public function testSetRangeInvalidRange(): void + /** + * @dataProvider invalidTableRangeProvider + */ + public function testSetRangeInvalidRange(string $range): void { $this->expectException(PhpSpreadsheetException::class); - $expectedResult = 'A1'; - $sheet = $this->getSheet(); - $table = new Table($expectedResult, $sheet); + new Table($range, $sheet); + } + + public function invalidTableRangeProvider(): array + { + return [ + ['A1'], + ['A1:A1'], + ['B1:A4'], + ['A1:D1'], + ['D1:A1'], + ]; } public function testGetColumnsEmpty(): void From 44d63f027aedc865342e1f87e30234c91a5df359 Mon Sep 17 00:00:00 2001 From: aswinkumar863 Date: Sun, 17 Apr 2022 17:46:59 +0530 Subject: [PATCH 32/52] Fixed coding standard with return typehints --- src/PhpSpreadsheet/Worksheet/Table.php | 74 +++++-------------- src/PhpSpreadsheet/Worksheet/Table/Column.php | 56 ++++---------- .../Worksheet/Table/TableStyle.php | 48 +++--------- src/PhpSpreadsheet/Writer/Xlsx/Table.php | 2 +- 4 files changed, 46 insertions(+), 134 deletions(-) diff --git a/src/PhpSpreadsheet/Worksheet/Table.php b/src/PhpSpreadsheet/Worksheet/Table.php index 0198dd2e..b2d29ce7 100644 --- a/src/PhpSpreadsheet/Worksheet/Table.php +++ b/src/PhpSpreadsheet/Worksheet/Table.php @@ -73,20 +73,16 @@ class Table /** * Get Table name. - * - * @return string */ - public function getName() + public function getName(): string { return $this->name; } /** * Set Table name. - * - * @return $this */ - public function setName(string $name) + public function setName(string $name): self { $name = trim($name); @@ -117,20 +113,16 @@ class Table /** * Get show Header Row. - * - * @return bool */ - public function getShowHeaderRow() + public function getShowHeaderRow(): bool { return $this->showHeaderRow; } /** * Set show Header Row. - * - * @return $this */ - public function setShowHeaderRow(bool $showHeaderRow) + public function setShowHeaderRow(bool $showHeaderRow): self { $this->showHeaderRow = $showHeaderRow; @@ -139,20 +131,16 @@ class Table /** * Get show Totals Row. - * - * @return bool */ - public function getShowTotalsRow() + public function getShowTotalsRow(): bool { return $this->showTotalsRow; } /** * Set show Totals Row. - * - * @return $this */ - public function setShowTotalsRow(bool $showTotalsRow) + public function setShowTotalsRow(bool $showTotalsRow): self { $this->showTotalsRow = $showTotalsRow; @@ -161,18 +149,14 @@ class Table /** * Get Table Range. - * - * @return string */ - public function getRange() + public function getRange(): string { return $this->range; } /** * Set Table Cell Range. - * - * @return $this */ public function setRange(string $range): self { @@ -210,8 +194,6 @@ class Table /** * Set Table Cell Range to max row. - * - * @return $this */ public function setRangeToMaxRow(): self { @@ -228,20 +210,16 @@ class Table /** * Get Table's Worksheet. - * - * @return null|Worksheet */ - public function getWorksheet() + public function getWorksheet(): ?Worksheet { return $this->workSheet; } /** * Set Table's Worksheet. - * - * @return $this */ - public function setWorksheet(?Worksheet $worksheet = null) + public function setWorksheet(?Worksheet $worksheet = null): self { if ($this->name !== '' && $worksheet !== null) { $spreadsheet = $worksheet->getParent(); @@ -265,7 +243,7 @@ class Table * * @return Table\Column[] */ - public function getColumns() + public function getColumns(): array { return $this->columns; } @@ -277,7 +255,7 @@ class Table * * @return int The column offset within the table range */ - public function isColumnInRange($column) + public function isColumnInRange(string $column): int { if (empty($this->range)) { throw new PhpSpreadsheetException('No table range is defined.'); @@ -299,7 +277,7 @@ class Table * * @return int The offset of the specified column within the table range */ - public function getColumnOffset($column) + public function getColumnOffset($column): int { return $this->isColumnInRange($column); } @@ -308,10 +286,8 @@ class Table * Get a specified Table Column. * * @param string $column Column name (e.g. A) - * - * @return Table\Column */ - public function getColumn($column) + public function getColumn($column): Table\Column { $this->isColumnInRange($column); @@ -326,10 +302,8 @@ class Table * 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) + public function getColumnByOffset($columnOffset): Table\Column { [$rangeStart, $rangeEnd] = Coordinate::rangeBoundaries($this->range); $pColumn = Coordinate::stringFromColumnIndex($rangeStart[0] + $columnOffset); @@ -342,10 +316,8 @@ class Table * * @param string|Table\Column $columnObjectOrString * A simple string containing a Column ID like 'A' is permitted - * - * @return $this */ - public function setColumn($columnObjectOrString) + public function setColumn($columnObjectOrString): self { if ((is_string($columnObjectOrString)) && (!empty($columnObjectOrString))) { $column = $columnObjectOrString; @@ -371,10 +343,8 @@ class Table * Clear a specified Table Column. * * @param string $column Column name (e.g. A) - * - * @return $this */ - public function clearColumn($column) + public function clearColumn($column): self { $this->isColumnInRange($column); @@ -394,10 +364,8 @@ class Table * * @param string $fromColumn Column name (e.g. A) * @param string $toColumn Column name (e.g. B) - * - * @return $this */ - public function shiftColumn($fromColumn, $toColumn) + public function shiftColumn($fromColumn, $toColumn): self { $fromColumn = strtoupper($fromColumn); $toColumn = strtoupper($toColumn); @@ -417,20 +385,16 @@ class Table /** * Get table Style. - * - * @return TableStyle */ - public function getStyle() + public function getStyle(): Table\TableStyle { return $this->style; } /** * Set table Style. - * - * @return $this */ - public function setStyle(TableStyle $style) + public function setStyle(TableStyle $style): self { $this->style = $style; diff --git a/src/PhpSpreadsheet/Worksheet/Table/Column.php b/src/PhpSpreadsheet/Worksheet/Table/Column.php index 385ba171..a7c445f5 100644 --- a/src/PhpSpreadsheet/Worksheet/Table/Column.php +++ b/src/PhpSpreadsheet/Worksheet/Table/Column.php @@ -69,10 +69,8 @@ class Column /** * Get Table column index as string eg: 'A'. - * - * @return string */ - public function getColumnIndex() + public function getColumnIndex(): string { return $this->columnIndex; } @@ -81,10 +79,8 @@ class Column * Set Table column index as string eg: 'A'. * * @param string $column Column (e.g. A) - * - * @return $this */ - public function setColumnIndex($column) + public function setColumnIndex($column): self { // Uppercase coordinate $column = strtoupper($column); @@ -99,20 +95,16 @@ class Column /** * Get show Filter Button. - * - * @return bool */ - public function getShowFilterButton() + public function getShowFilterButton(): bool { return $this->showFilterButton; } /** * Set show Filter Button. - * - * @return $this */ - public function setShowFilterButton(bool $showFilterButton) + public function setShowFilterButton(bool $showFilterButton): self { $this->showFilterButton = $showFilterButton; @@ -121,20 +113,16 @@ class Column /** * Get total Row Label. - * - * @return string */ - public function getTotalsRowLabel() + public function getTotalsRowLabel(): ?string { return $this->totalsRowLabel; } /** * Set total Row Label. - * - * @return $this */ - public function setTotalsRowLabel(string $totalsRowLabel) + public function setTotalsRowLabel(string $totalsRowLabel): self { $this->totalsRowLabel = $totalsRowLabel; @@ -143,20 +131,16 @@ class Column /** * Get total Row Function. - * - * @return string */ - public function getTotalsRowFunction() + public function getTotalsRowFunction(): ?string { return $this->totalsRowFunction; } /** * Set total Row Function. - * - * @return $this */ - public function setTotalsRowFunction(string $totalsRowFunction) + public function setTotalsRowFunction(string $totalsRowFunction): self { $this->totalsRowFunction = $totalsRowFunction; @@ -165,20 +149,16 @@ class Column /** * Get total Row Formula. - * - * @return string */ - public function getTotalsRowFormula() + public function getTotalsRowFormula(): ?string { return $this->totalsRowFormula; } /** * Set total Row Formula. - * - * @return $this */ - public function setTotalsRowFormula(string $totalsRowFormula) + public function setTotalsRowFormula(string $totalsRowFormula): self { $this->totalsRowFormula = $totalsRowFormula; @@ -187,20 +167,16 @@ class Column /** * Get column Formula. - * - * @return string */ - public function getColumnFormula() + public function getColumnFormula(): ?string { return $this->columnFormula; } /** * Set column Formula. - * - * @return $this */ - public function setColumnFormula(string $columnFormula) + public function setColumnFormula(string $columnFormula): self { $this->columnFormula = $columnFormula; @@ -209,20 +185,16 @@ class Column /** * Get this Column's Table. - * - * @return null|Table */ - public function getTable() + public function getTable(): ?Table { return $this->table; } /** * Set this Column's Table. - * - * @return $this */ - public function setTable(?Table $table = null) + public function setTable(?Table $table = null): self { $this->table = $table; diff --git a/src/PhpSpreadsheet/Worksheet/Table/TableStyle.php b/src/PhpSpreadsheet/Worksheet/Table/TableStyle.php index ccf13729..78643c72 100644 --- a/src/PhpSpreadsheet/Worksheet/Table/TableStyle.php +++ b/src/PhpSpreadsheet/Worksheet/Table/TableStyle.php @@ -122,20 +122,16 @@ class TableStyle /** * Get theme. - * - * @return string */ - public function getTheme() + public function getTheme(): string { return $this->theme; } /** * Set theme. - * - * @return $this */ - public function setTheme(string $theme) + public function setTheme(string $theme): self { $this->theme = $theme; @@ -144,20 +140,16 @@ class TableStyle /** * Get show First Column. - * - * @return bool */ - public function getShowFirstColumn() + public function getShowFirstColumn(): bool { return $this->showFirstColumn; } /** * Set show First Column. - * - * @return $this */ - public function setShowFirstColumn(bool $showFirstColumn) + public function setShowFirstColumn(bool $showFirstColumn): self { $this->showFirstColumn = $showFirstColumn; @@ -166,20 +158,16 @@ class TableStyle /** * Get show Last Column. - * - * @return bool */ - public function getShowLastColumn() + public function getShowLastColumn(): bool { return $this->showLastColumn; } /** * Set show Last Column. - * - * @return $this */ - public function setShowLastColumn(bool $showLastColumn) + public function setShowLastColumn(bool $showLastColumn): self { $this->showLastColumn = $showLastColumn; @@ -188,20 +176,16 @@ class TableStyle /** * Get show Row Stripes. - * - * @return bool */ - public function getShowRowStripes() + public function getShowRowStripes(): bool { return $this->showRowStripes; } /** * Set show Row Stripes. - * - * @return $this */ - public function setShowRowStripes(bool $showRowStripes) + public function setShowRowStripes(bool $showRowStripes): self { $this->showRowStripes = $showRowStripes; @@ -210,20 +194,16 @@ class TableStyle /** * Get show Column Stripes. - * - * @return bool */ - public function getShowColumnStripes() + public function getShowColumnStripes(): bool { return $this->showColumnStripes; } /** * Set show Column Stripes. - * - * @return $this */ - public function setShowColumnStripes(bool $showColumnStripes) + public function setShowColumnStripes(bool $showColumnStripes): self { $this->showColumnStripes = $showColumnStripes; @@ -232,20 +212,16 @@ class TableStyle /** * Get this Style's Table. - * - * @return null|Table */ - public function getTable() + public function getTable(): ?Table { return $this->table; } /** * Set this Style's Table. - * - * @return $this */ - public function setTable(?Table $table = null) + public function setTable(?Table $table = null): self { $this->table = $table; diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Table.php b/src/PhpSpreadsheet/Writer/Xlsx/Table.php index be9f5183..67dbd19b 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Table.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Table.php @@ -15,7 +15,7 @@ class Table extends WriterPart * * @return string XML Output */ - public function writeTable(WorksheetTable $table, $tableRef) + public function writeTable(WorksheetTable $table, $tableRef): string { // Create XML writer $objWriter = null; From 530e6642bf26522176504fccc81385d7358936d2 Mon Sep 17 00:00:00 2001 From: aswinkumar863 Date: Sun, 17 Apr 2022 17:48:38 +0530 Subject: [PATCH 33/52] Table name as an constructor argument Replaced worksheet argument with table name --- samples/Table/01_Table.php | 4 +- samples/Table/03_Column_Formula.php | 3 +- src/PhpSpreadsheet/Worksheet/Table.php | 43 +++++----- src/PhpSpreadsheet/Worksheet/Worksheet.php | 6 +- .../Worksheet/Table/RemoveTableTest.php | 4 +- .../Worksheet/Table/TableStyleTest.php | 6 +- .../Worksheet/Table/TableTest.php | 78 +++++++------------ 7 files changed, 63 insertions(+), 81 deletions(-) diff --git a/samples/Table/01_Table.php b/samples/Table/01_Table.php index c8301680..476f187c 100644 --- a/samples/Table/01_Table.php +++ b/samples/Table/01_Table.php @@ -52,9 +52,7 @@ $spreadsheet->getActiveSheet()->fromArray($dataArray, null, 'A2'); // Create Table $helper->log('Create Table'); -$table = new Table(); -$table->setName('Sales_Data'); -$table->setRange('A1:D17'); +$table = new Table('A1:D17', 'Sales_Data'); // Create Columns $table->getColumn('D')->setShowFilterButton(false); diff --git a/samples/Table/03_Column_Formula.php b/samples/Table/03_Column_Formula.php index e8ae5d99..b431af34 100644 --- a/samples/Table/03_Column_Formula.php +++ b/samples/Table/03_Column_Formula.php @@ -49,8 +49,7 @@ $spreadsheet->getActiveSheet()->fromArray($dataArray, null, 'A1'); // Create Table $helper->log('Create Table'); -$table = new Table(); -$table->setName('Sales_Data'); +$table = new Table('A1:G15', 'Sales_Data'); $table->setRange('A1:G15'); // Set Column Formula diff --git a/src/PhpSpreadsheet/Worksheet/Table.php b/src/PhpSpreadsheet/Worksheet/Table.php index b2d29ce7..35966d6f 100644 --- a/src/PhpSpreadsheet/Worksheet/Table.php +++ b/src/PhpSpreadsheet/Worksheet/Table.php @@ -61,13 +61,14 @@ class Table * Create a new Table. * * @param string $range (e.g. A1:D4) + * @param string $name (e.g. Table1) * * @return $this */ - public function __construct(string $range = '', ?Worksheet $worksheet = null) + public function __construct(string $range = '', string $name = '') { $this->setRange($range); - $this->setWorksheet($worksheet); + $this->setName($name); $this->style = new TableStyle(); } @@ -86,24 +87,26 @@ class Table { $name = trim($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'); + 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; diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index 2facf172..6f04d2ba 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -2212,7 +2212,11 @@ class Worksheet implements IComparable { $cellRange = Coordinate::stringFromColumnIndex($columnIndex1) . $row1 . ':' . Coordinate::stringFromColumnIndex($columnIndex2) . $row2; - return $this->addTable(new Table($cellRange, $this)); + $table = new Table($cellRange); + $table->setWorksheet($this); + $this->addTable($table); + + return $this; } /** diff --git a/tests/PhpSpreadsheetTests/Worksheet/Table/RemoveTableTest.php b/tests/PhpSpreadsheetTests/Worksheet/Table/RemoveTableTest.php index b33e6c79..0495b11c 100644 --- a/tests/PhpSpreadsheetTests/Worksheet/Table/RemoveTableTest.php +++ b/tests/PhpSpreadsheetTests/Worksheet/Table/RemoveTableTest.php @@ -12,7 +12,7 @@ class RemoveTableTest extends SetupTeardown { $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); $table->setName('Table1'); $sheet->addTable($table); @@ -26,7 +26,7 @@ class RemoveTableTest extends SetupTeardown { $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); $table->setName('Table1'); $sheet->addTable($table); diff --git a/tests/PhpSpreadsheetTests/Worksheet/Table/TableStyleTest.php b/tests/PhpSpreadsheetTests/Worksheet/Table/TableStyleTest.php index e3cdaa7c..830cc87b 100644 --- a/tests/PhpSpreadsheetTests/Worksheet/Table/TableStyleTest.php +++ b/tests/PhpSpreadsheetTests/Worksheet/Table/TableStyleTest.php @@ -11,8 +11,7 @@ class TableStyleTest extends SetupTeardown public function testVariousSets(): void { - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); $style = $table->getStyle(); $result = $style->setTheme(TableStyle::TABLE_STYLE_DARK1); @@ -38,8 +37,7 @@ class TableStyleTest extends SetupTeardown public function testTable(): void { - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $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 index 9602f27d..af205196 100644 --- a/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php +++ b/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php @@ -14,8 +14,7 @@ class TableTest extends SetupTeardown public function testToString(): void { $expectedResult = self::INITIAL_RANGE; - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); // magic __toString should return the active table range $result = (string) $table; @@ -27,8 +26,7 @@ class TableTest extends SetupTeardown */ public function testValidTableNames(string $name, string $expected): void { - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); $result = $table->setName($name); self::assertInstanceOf(Table::class, $result); @@ -38,6 +36,7 @@ class TableTest extends SetupTeardown public function validTableNamesProvider(): array { return [ + ['', ''], ['Table_1', 'Table_1'], ['_table_2', '_table_2'], ['\table_3', '\table_3'], @@ -52,8 +51,7 @@ class TableTest extends SetupTeardown */ public function testInvalidTableNames(string $name): void { - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); $this->expectException(PhpSpreadsheetException::class); @@ -95,8 +93,7 @@ class TableTest extends SetupTeardown public function testVariousSets(): void { - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); $result = $table->setShowHeaderRow(false); self::assertInstanceOf(Table::class, $result); @@ -110,15 +107,15 @@ class TableTest extends SetupTeardown public function testGetWorksheet(): void { $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); + $sheet->addTable($table); $result = $table->getWorksheet(); self::assertSame($sheet, $result); } public function testSetWorksheet(): void { - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); $spreadsheet = $this->getSpreadsheet(); $sheet2 = $spreadsheet->createSheet(); // Setters return the instance to implement the fluent interface @@ -129,8 +126,7 @@ class TableTest extends SetupTeardown public function testGetRange(): void { $expectedResult = self::INITIAL_RANGE; - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); // Result should be the active table range $result = $table->getRange(); @@ -141,7 +137,7 @@ class TableTest extends SetupTeardown { $sheet = $this->getSheet(); $title = $sheet->getTitle(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); $ranges = [ 'G1:J512' => "$title!G1:J512", 'K1:N20' => 'K1:N20', @@ -161,8 +157,7 @@ class TableTest extends SetupTeardown public function testClearRange(): void { $expectedResult = ''; - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); // Setters return the instance to implement the fluent interface $result = $table->setRange(''); @@ -180,8 +175,7 @@ class TableTest extends SetupTeardown { $this->expectException(PhpSpreadsheetException::class); - $sheet = $this->getSheet(); - new Table($range, $sheet); + new Table($range); } public function invalidTableRangeProvider(): array @@ -198,8 +192,7 @@ class TableTest extends SetupTeardown public function testGetColumnsEmpty(): void { // There should be no columns yet defined - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); $result = $table->getColumns(); self::assertIsArray($result); self::assertCount(0, $result); @@ -212,8 +205,7 @@ class TableTest extends SetupTeardown 'K' => 3, 'M' => 5, ]; - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $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 @@ -296,8 +288,7 @@ class TableTest extends SetupTeardown public function testSetColumnWithString(): void { $expectedResult = 'L'; - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); // Setters return the instance to implement the fluent interface $result = $table->setColumn($expectedResult); @@ -315,8 +306,7 @@ class TableTest extends SetupTeardown public function testSetInvalidColumnWithString(): void { $this->expectException(PhpSpreadsheetException::class); - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); $invalidColumn = 'A'; $table->setColumn($invalidColumn); @@ -326,8 +316,7 @@ class TableTest extends SetupTeardown { $expectedResult = 'M'; $columnObject = new Column($expectedResult); - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); // Setters return the instance to implement the fluent interface $result = $table->setColumn($columnObject); @@ -347,8 +336,7 @@ class TableTest extends SetupTeardown $this->expectException(PhpSpreadsheetException::class); $invalidColumn = 'E'; - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); $table->setColumn($invalidColumn); } @@ -356,8 +344,7 @@ class TableTest extends SetupTeardown { $this->expectException(PhpSpreadsheetException::class); - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); $invalidColumn = 123.456; // @phpstan-ignore-next-line $table->setColumn($invalidColumn); @@ -365,8 +352,7 @@ class TableTest extends SetupTeardown public function testGetColumns(): void { - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); $columnIndexes = ['L', 'M']; @@ -391,8 +377,7 @@ class TableTest extends SetupTeardown public function testGetColumn(): void { - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); $columnIndexes = ['L', 'M']; @@ -410,8 +395,7 @@ class TableTest extends SetupTeardown public function testGetColumnByOffset(): void { - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); $columnIndexes = [ 0 => 'H', @@ -430,8 +414,7 @@ class TableTest extends SetupTeardown public function testGetColumnIfNotSet(): void { - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $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'); @@ -441,8 +424,7 @@ class TableTest extends SetupTeardown public function testGetColumnWithoutRangeSet(): void { $this->expectException(\PhpOffice\PhpSpreadsheet\Exception::class); - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); // Clear the range $table->setRange(''); @@ -451,8 +433,7 @@ class TableTest extends SetupTeardown public function testClearRangeWithExistingColumns(): void { - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); $expectedResult = ''; $columnIndexes = ['L', 'M', 'N']; @@ -476,8 +457,7 @@ class TableTest extends SetupTeardown public function testSetRangeWithExistingColumns(): void { - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); $expectedResult = 'G1:J512'; // These columns should be retained @@ -509,7 +489,8 @@ class TableTest extends SetupTeardown public function testClone(): void { $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); + $sheet->addTable($table); $columnIndexes = ['L', 'M']; foreach ($columnIndexes as $columnIndex) { @@ -546,8 +527,7 @@ class TableTest extends SetupTeardown public function testClearColumn(): void { - $sheet = $this->getSheet(); - $table = new Table(self::INITIAL_RANGE, $sheet); + $table = new Table(self::INITIAL_RANGE); $columnIndexes = ['J', 'K', 'L', 'M']; foreach ($columnIndexes as $columnIndex) { From d5936172872d255a31855473c617f1b581bee07f Mon Sep 17 00:00:00 2001 From: redforks Date: Sun, 17 Apr 2022 23:27:28 +0800 Subject: [PATCH 34/52] Fix font index problem (#2642) * Fix font index problem * Update RichTextSizeTest.php Eliminate Phpstan failure. * Update RichTextSizeTest.php Eliminate now-unused import. --- src/PhpSpreadsheet/Reader/Xls.php | 9 +++++-- .../Reader/Xls/RichTextSizeTest.php | 22 ++++++++++++++++++ tests/data/Reader/XLS/RichTextFontSize.xls | Bin 0 -> 53760 bytes 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Reader/Xls/RichTextSizeTest.php create mode 100644 tests/data/Reader/XLS/RichTextFontSize.xls diff --git a/src/PhpSpreadsheet/Reader/Xls.php b/src/PhpSpreadsheet/Reader/Xls.php index 402fea9f..d1f14ae1 100644 --- a/src/PhpSpreadsheet/Reader/Xls.php +++ b/src/PhpSpreadsheet/Reader/Xls.php @@ -3759,8 +3759,13 @@ class Xls extends BaseReader } else { $textRun = $richText->createTextRun($text); if (isset($fmtRuns[$i - 1])) { - $fontIndex = $fmtRuns[$i - 1]['fontIndex']; - + if ($fmtRuns[$i - 1]['fontIndex'] < 4) { + $fontIndex = $fmtRuns[$i - 1]['fontIndex']; + } else { + // this has to do with that index 4 is omitted in all BIFF versions for some stra nge reason + // check the OpenOffice documentation of the FONT record + $fontIndex = $fmtRuns[$i - 1]['fontIndex'] - 1; + } if (array_key_exists($fontIndex, $this->objFonts) === false) { $fontIndex = count($this->objFonts) - 1; } diff --git a/tests/PhpSpreadsheetTests/Reader/Xls/RichTextSizeTest.php b/tests/PhpSpreadsheetTests/Reader/Xls/RichTextSizeTest.php new file mode 100644 index 00000000..54274e8c --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Xls/RichTextSizeTest.php @@ -0,0 +1,22 @@ +load($filename); + $sheet = $spreadsheet->getSheetByName('橱柜门板'); + self::assertNotNull($sheet); + $text = $sheet->getCell('L15')->getValue(); + $elements = $text->getRichTextElements(); + self::assertEquals(10, $elements[2]->getFont()->getSize()); + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/data/Reader/XLS/RichTextFontSize.xls b/tests/data/Reader/XLS/RichTextFontSize.xls new file mode 100644 index 0000000000000000000000000000000000000000..3edf47379c273edebb3c30c2484a4eb69a081fc6 GIT binary patch literal 53760 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 literal 0 HcmV?d00001 From 9545b7a0d1ff8b6a4feff1e5e13d25d2ba8bdd28 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Sun, 17 Apr 2022 08:44:29 -0700 Subject: [PATCH 35/52] Fix Fluke Failure in Document Properties Test (#2738) PR #2720 failed because a timestamp in Document Properties Test was off by 1. This was due to one of two possible reasons. The constructor for Properties set the Created and Modified times using separate calls to the time function; if those happened to occur in different seconds, the test would fail. The test might also fail if the Created and Modified times used the same timestamp, but the time used to compare against those was calculated in a different second. It is surprising that this failure hasn't shown up before. Regardless, this PR corrects both possible problems. --- src/PhpSpreadsheet/Document/Properties.php | 2 +- .../Document/PropertiesTest.php | 24 ++++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/PhpSpreadsheet/Document/Properties.php b/src/PhpSpreadsheet/Document/Properties.php index 3be5a67a..2d461c59 100644 --- a/src/PhpSpreadsheet/Document/Properties.php +++ b/src/PhpSpreadsheet/Document/Properties.php @@ -115,7 +115,7 @@ class Properties // Initialise values $this->lastModifiedBy = $this->creator; $this->created = self::intOrFloatTimestamp(null); - $this->modified = self::intOrFloatTimestamp(null); + $this->modified = $this->created; } /** diff --git a/tests/PhpSpreadsheetTests/Document/PropertiesTest.php b/tests/PhpSpreadsheetTests/Document/PropertiesTest.php index e6c95cd4..0dfb256b 100644 --- a/tests/PhpSpreadsheetTests/Document/PropertiesTest.php +++ b/tests/PhpSpreadsheetTests/Document/PropertiesTest.php @@ -2,6 +2,7 @@ namespace PhpOffice\PhpSpreadsheetTests\Document; +use DateTime; use DateTimeZone; use PhpOffice\PhpSpreadsheet\Document\Properties; use PhpOffice\PhpSpreadsheet\Shared\Date; @@ -14,20 +15,27 @@ class PropertiesTest extends TestCase */ private $properties; + /** @var float */ + private $startTime; + protected function setup(): void { - $this->properties = new Properties(); + do { + // loop to avoid rare situation where timestamp changes + $this->startTime = (float) (new DateTime())->format('U'); + $this->properties = new Properties(); + $endTime = (float) (new DateTime())->format('U'); + } while ($this->startTime !== $endTime); } public function testNewInstance(): void { - $createdTime = $modifiedTime = time(); self::assertSame('Unknown Creator', $this->properties->getCreator()); self::assertSame('Unknown Creator', $this->properties->getLastModifiedBy()); self::assertSame('Untitled Spreadsheet', $this->properties->getTitle()); self::assertSame('', $this->properties->getCompany()); - self::assertSame($createdTime, $this->properties->getCreated()); - self::assertSame($modifiedTime, $this->properties->getModified()); + self::assertEquals($this->startTime, $this->properties->getCreated()); + self::assertEquals($this->startTime, $this->properties->getModified()); } public function testSetCreator(): void @@ -46,10 +54,10 @@ class PropertiesTest extends TestCase */ public function testSetCreated($expectedCreationTime, $created): void { - $expectedCreationTime = $expectedCreationTime ?? time(); + $expectedCreationTime = $expectedCreationTime ?? $this->startTime; $this->properties->setCreated($created); - self::assertSame($expectedCreationTime, $this->properties->getCreated()); + self::assertEquals($expectedCreationTime, $this->properties->getCreated()); } public function providerCreationTime(): array @@ -78,10 +86,10 @@ class PropertiesTest extends TestCase */ public function testSetModified($expectedModifiedTime, $modified): void { - $expectedModifiedTime = $expectedModifiedTime ?? time(); + $expectedModifiedTime = $expectedModifiedTime ?? $this->startTime; $this->properties->setModified($modified); - self::assertSame($expectedModifiedTime, $this->properties->getModified()); + self::assertEquals($expectedModifiedTime, $this->properties->getModified()); } public function providerModifiedTime(): array From ea584301c7feb3e3617feaa58d2bef2361492690 Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Sun, 17 Apr 2022 21:50:52 +0200 Subject: [PATCH 36/52] Modify Autosize calculation to make allowance for the filter dropdown icon in the first row of an AutoFilter range --- CHANGELOG.md | 1 + src/PhpSpreadsheet/Shared/Font.php | 28 ++++++++++++++++------ src/PhpSpreadsheet/Worksheet/Worksheet.php | 28 ++++++++++++++++++---- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ceda96f0..e136eab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Fixed +- Make allowance for the AutoFilter dropdown icon in the first row of an Autofilter range when using Autosize columns. [Issue #2413](https://github.com/PHPOffice/PhpSpreadsheet/issues/2413) [PR #2754](https://github.com/PHPOffice/PhpSpreadsheet/pull/2754) - Support for "chained" ranges (e.g. `A5:C10:C20:F1`) in the Calculation Engine; and also support for using named ranges with the Range operator (e.g. `NamedRange1:NamedRange2`) [Issue #2730](https://github.com/PHPOffice/PhpSpreadsheet/issues/2730) [PR #2746](https://github.com/PHPOffice/PhpSpreadsheet/pull/2746) - Update Conditional Formatting ranges and rule conditions when inserting/deleting rows/columns [Issue #2678](https://github.com/PHPOffice/PhpSpreadsheet/issues/2678) [PR #2689](https://github.com/PHPOffice/PhpSpreadsheet/pull/2689) - Allow `INDIRECT()` to accept row/column ranges as well as cell ranges [PR #2687](https://github.com/PHPOffice/PhpSpreadsheet/pull/2687) diff --git a/src/PhpSpreadsheet/Shared/Font.php b/src/PhpSpreadsheet/Shared/Font.php index 9a74befe..3ee3d5a6 100644 --- a/src/PhpSpreadsheet/Shared/Font.php +++ b/src/PhpSpreadsheet/Shared/Font.php @@ -222,11 +222,15 @@ class Font * @param RichText|string $cellText Text to calculate width * @param int $rotation Rotation angle * @param null|FontStyle $defaultFont Font object - * - * @return int Column width + * @param bool $filterAdjustment Add space for Autofilter or Table dropdown */ - public static function calculateColumnWidth(FontStyle $font, $cellText = '', $rotation = 0, ?FontStyle $defaultFont = null) - { + public static function calculateColumnWidth( + FontStyle $font, + $cellText = '', + $rotation = 0, + ?FontStyle $defaultFont = null, + bool $filterAdjustment = false + ): int { // If it is rich text, use plain text if ($cellText instanceof RichText) { $cellText = $cellText->getPlainText(); @@ -237,7 +241,7 @@ class Font $lineTexts = explode("\n", $cellText); $lineWidths = []; foreach ($lineTexts as $lineText) { - $lineWidths[] = self::calculateColumnWidth($font, $lineText, $rotation = 0, $defaultFont); + $lineWidths[] = self::calculateColumnWidth($font, $lineText, $rotation = 0, $defaultFont, $filterAdjustment); } return max($lineWidths); // width of longest line in cell @@ -247,7 +251,13 @@ class Font $approximate = self::$autoSizeMethod == self::AUTOSIZE_METHOD_APPROX; $columnWidth = 0; if (!$approximate) { - $columnWidthAdjust = ceil(self::getTextWidthPixelsExact('n', $font, 0) * 1.07); + $columnWidthAdjust = ceil( + self::getTextWidthPixelsExact( + str_repeat('n', 1 * ($filterAdjustment ? 3 : 1)), + $font, + 0 + ) * 1.07 + ); try { // Width of text in pixels excl. padding @@ -259,7 +269,11 @@ class Font } if ($approximate) { - $columnWidthAdjust = self::getTextWidthPixelsApprox('n', $font, 0); + $columnWidthAdjust = self::getTextWidthPixelsApprox( + str_repeat('n', 1 * ($filterAdjustment ? 3 : 1)), + $font, + 0 + ); // Width of text in pixels excl. padding, approximation // and addition because Excel adds some padding, just use approx width of 'n' glyph $columnWidth = self::getTextWidthPixelsApprox($cellText, $font, $rotation) + $columnWidthAdjust; diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index a1bfbfdb..e4ce2ac3 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -733,9 +733,19 @@ class Worksheet implements IComparable } } + $autoFilterRange = $autoFilterFirstRowRange = $this->autoFilter->getRange(); + if (!empty($autoFilterRange)) { + $autoFilterRangeBoundaries = Coordinate::rangeBoundaries($autoFilterRange); + $autoFilterFirstRowRange = (string) new CellRange( + CellAddress::fromColumnAndRow($autoFilterRangeBoundaries[0][0], $autoFilterRangeBoundaries[0][1]), + CellAddress::fromColumnAndRow($autoFilterRangeBoundaries[1][0], $autoFilterRangeBoundaries[0][1]) + ); + } + // loop through all cells in the worksheet foreach ($this->getCoordinates(false) as $coordinate) { $cell = $this->getCellOrNull($coordinate); + if ($cell !== null && isset($autoSizes[$this->cellCollection->getCurrentColumn()])) { //Determine if cell is in merge range $isMerged = isset($isMergeCell[$this->cellCollection->getCurrentCoordinate()]); @@ -752,13 +762,21 @@ class Worksheet implements IComparable } } - // Determine width if cell does not participate in a merge or does and is a value cell of 1-column wide range + // Determine width if cell is not part of a merge or does and is a value cell of 1-column wide range if (!$isMerged || $isMergedButProceed) { + // Determine if we need to make an adjustment for the first row in an AutoFilter range that + // has a column filter dropdown + $filterAdjustment = false; + if (!empty($autoFilterRange) && $cell->isInRange($autoFilterFirstRowRange)) { + $filterAdjustment = true; + } + // Calculated value // To formatted string $cellValue = NumberFormat::toFormattedString( $cell->getCalculatedValue(), - $this->getParent()->getCellXfByIndex($cell->getXfIndex())->getNumberFormat()->getFormatCode() + $this->getParent()->getCellXfByIndex($cell->getXfIndex()) + ->getNumberFormat()->getFormatCode() ); if ($cellValue !== null && $cellValue !== '') { @@ -767,8 +785,10 @@ class Worksheet implements IComparable (float) Shared\Font::calculateColumnWidth( $this->getParent()->getCellXfByIndex($cell->getXfIndex())->getFont(), $cellValue, - $this->getParent()->getCellXfByIndex($cell->getXfIndex())->getAlignment()->getTextRotation(), - $this->getParent()->getDefaultStyle()->getFont() + $this->getParent()->getCellXfByIndex($cell->getXfIndex()) + ->getAlignment()->getTextRotation(), + $this->getParent()->getDefaultStyle()->getFont(), + $filterAdjustment ) ); } From 4cd1d7039d1db8e4994a28eb7f7a6566dbb3b7d2 Mon Sep 17 00:00:00 2001 From: andres1gb Date: Mon, 18 Apr 2022 15:54:41 +0200 Subject: [PATCH 37/52] Fix reading of files in the root of a zip (#2731) * Fix reading of files in the root of a zip Xlsx.php relies in dirname($filename) for path generation. When path is a bare filename (i.e. files in the root of the zip file), dirname($filename) returns a relative path to the current directory ("."). This is ok for filesystems, but not when accesing contents in a zip file. Xlsx documents with files in the root of the zip container are not common, but legit. I've found it to happen in files generated by Google Campaign Manager 360. * Update Xlsx.php * Update Xlsx.php * Update CHANGELOG.md * Add files via upload * Create XlsxRootZipFilesTest.php * Update XlsxRootZipFilesTest.php * Add files via upload * Delete rootZipFiles.xlsx * Update XlsxRootZipFilesTest.php * Update Xlsx.php --- CHANGELOG.md | 2 +- src/PhpSpreadsheet/Reader/Xlsx.php | 3 +++ .../Reader/Xlsx/XlsxRootZipFilesTest.php | 24 ++++++++++++++++++ tests/data/Reader/XLSX/rootZipFiles.xlsx | Bin 0 -> 3363 bytes 4 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 tests/PhpSpreadsheetTests/Reader/Xlsx/XlsxRootZipFilesTest.php create mode 100644 tests/data/Reader/XLSX/rootZipFiles.xlsx diff --git a/CHANGELOG.md b/CHANGELOG.md index e136eab6..d5ef98a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,9 +82,9 @@ and this project adheres to [Semantic Versioning](https://semver.org). Nor is this a perfect solution, as there may still be issues when function calls have array arguments that themselves contain function calls; but it's still better than the current logic. - Fix for escaping double quotes within a formula [Issue #1971](https://github.com/PHPOffice/PhpSpreadsheet/issues/1971) [PR #2651](https://github.com/PHPOffice/PhpSpreadsheet/pull/2651) +- Fix for reading files in the root directory of a ZipFile, which should not be prefixed by relative paths ("./") as dirname($filename) does by default. - Fix invalid style of cells in empty columns with columnDimensions and rows with rowDimensions in added external sheet. [PR #2739](https://github.com/PHPOffice/PhpSpreadsheet/pull/2739) - ## 1.22.0 - 2022-02-18 ### Added diff --git a/src/PhpSpreadsheet/Reader/Xlsx.php b/src/PhpSpreadsheet/Reader/Xlsx.php index 18ed7987..a6e7fe03 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx.php +++ b/src/PhpSpreadsheet/Reader/Xlsx.php @@ -368,6 +368,9 @@ class Xlsx extends BaseReader if (strpos($fileName, '//') !== false) { $fileName = substr($fileName, strpos($fileName, '//') + 1); } + // Relative paths generated by dirname($filename) when $filename + // has no path (i.e.files in root of the zip archive) + $fileName = (string) preg_replace('/^\.\//', '', $fileName); $fileName = File::realpath($fileName); // Sadly, some 3rd party xlsx generators don't use consistent case for filenaming diff --git a/tests/PhpSpreadsheetTests/Reader/Xlsx/XlsxRootZipFilesTest.php b/tests/PhpSpreadsheetTests/Reader/Xlsx/XlsxRootZipFilesTest.php new file mode 100644 index 00000000..110c70b0 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Xlsx/XlsxRootZipFilesTest.php @@ -0,0 +1,24 @@ +load($filename); + $sheet = $spreadsheet->getActiveSheet(); + $value = $sheet->getCell('A1')->getValue(); + self::assertSame('TEST CELL', $value->getPlainText()); + } +} diff --git a/tests/data/Reader/XLSX/rootZipFiles.xlsx b/tests/data/Reader/XLSX/rootZipFiles.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..3de12790b6c7d3b026306170d2d04e83677e576a GIT binary patch literal 3363 zcmb7`c|2787soGyVQizU{gP<18`~&Jzid%yY|UgT>liA=Hj}cXWN)bOO0uPeBFZS0 zEsY7;X-uIc%Y?F2h({0o?(h_%dVY6acV74Yao(@<{hV_@=UA9RID`QJ;08Xx$e2=S zrZbEU0K(V-U@fp7IB4kYMZ$ZL95Detc%nV>1i=%F;Q=7;-S)eT!Z9w#PVfS39Ft(g zJS0D%+zg@)5C5tg!4yrGl!qG>n+hEcJtJl_T%IQKt+te>_+icQZI&%YyK|I1+~1`Z zm33rk5g&RVUo;s_l~ah8w)?2yqGW<79yy>Sz2yqdcnw1;=OTm{ z@*<_-G~4d1Sc=&UhTW$;sl3Tapul9dz+vanL>XLvzuw8uQ7HY?wn7#&IOPT1fMU&m=66;c3NEN-`S4&2D*V|n2Jxt z1}g8V>jQ#qvw#ICD-4ts0J4veG4l`q{BeP@I{M)~iK_ET?&in1K!jE($%B7fQWoYJ9t%DJc2?Wc67UWBh&C_sg)(UR39HoYNb<8-Yi`pRzBif zFrTNhbLh!ZI|+IjMNUOa;wB>7=ZuXwmacj2QM`MCqO1UuUZb0slnmY1Z-!FydLKys zE1r!&odZ&73Dc_W?Nf<;mj;_Op|cU^UM8DsXecE^n4t{zH-EphE!dQm>q9N`7yazZ zKV7l+8d)_gWajmFRKxY^?8B&O!sr*tw%Hl|4mr1c_FDI48?o`@q%Zc|j^B z`ld$&Hqb&P#xP|=#8*^7^e|WC(V_{#dNkdmq##31qbKU|k&T$TB4Gq_I1)O3!APjb z&XiQY!_cz7_d``L|MFA2G|j{pf+r4v0)PN`Y9cAX6Hh#h$CLi_m5yL*y(-n<OvwW;zcrv=q z?`WWvI$ONW`!qYXV+OI$V~wjr{mi)Z)ezenIWhogi)t&Iuq}Q{p3%^tu{Y*+2MDK& zWfRpEPBPq%7RHW4I?_$JLIm&JD%wZpJkdH=rYL*}`y|4*c7(0TST03$mfFSSF#fDu ziOvp_CDvvc>)1wVb7#0Wk5?67KH7wsc{|+crU$JnW@K3tCTko_CH2Lw45lmgVxQW+ zMy+kvF@2sy+sjP#IFjFBFG9$rH{4oJhQAW-oJrj0E{xia!?Kz57-~&k4T<6J3m&p^ z?4uDUQU}_r1U|?GXbbvvN21*O-G(>&-H2AKB1hyQ6hFkf$2_w=D02G#m+QyTjI$$k z9M<(nFAB}%*l<{itL`ZE}361N=*UfpO!v#=sMXZ3m41fTNxXvwOuOR zGRzcTU_R4H)p6RS`Px0R^l{nFc4F1mi>HoZ=Ol_B-=l2VD#+4i!D(}Ta#{$e%?Zy1 zn>G8B&9Z_I1`IxZ4GPO4z91|Zj5ONsxS!W=OulY5(+rCJQ^u@fip)3C-HBeID+z<;F1h28?L(bW>(wc@IX z`&GBzwd3Nj$0T!0{o9I9rdc>eH4;M}J9>%jq7)^q<&h4DUlUQz@5p@B3^xdto}76* zq<5*cdVnYWzfA2;dMNUGT;CdR1< zndqR<^fqC1EDhn6*>&$lOLTDN)4bvCW);rBy>Eg-n*FVis&A;OS)RC-Xe(* zlliIP>&>m<4YH`}D!p~zH#!% zx;}Hi44qGQtnHe-!^N#zoHhp}XmfWbl>~3j7M>Uhanb$q_I9g{6E?S0xBUEQ;ki*E z1W-0|-00&wjkI*aBgV8<<~o)4J;e{^P@ba_3jP-Y2jYf^LGKJJ``JXf^IEywA%tz{ zx*IL;6ok**?g}goV+7t)5UFRkl7{Rnyof4O*&=C#Fdx!W@3g`N&-gybS?X4B&s4 z7OEf4GMe+@IU AjsO4v literal 0 HcmV?d00001 From 0afec9106162ff4e2b9dbbecc87e16a51d3afc7d Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Mon, 18 Apr 2022 08:28:01 -0700 Subject: [PATCH 38/52] Change Log Updates (#2756) Catch up on some undocumented changes. --- CHANGELOG.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5ef98a1..1854e3f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,11 +19,13 @@ and this project adheres to [Semantic Versioning](https://semver.org). This functionality is locale-aware, using the server's locale settings to identify the thousands and decimal separators. -- Support for two cell anchor drawing of images. [#2532](https://github.com/PHPOffice/PhpSpreadsheet/pull/2532) +- Support for two cell anchor drawing of images. [#2532](https://github.com/PHPOffice/PhpSpreadsheet/pull/2532) [#2674](https://github.com/PHPOffice/PhpSpreadsheet/pull/2674) - Limited support for Xls Reader to handle Conditional Formatting: Ranges and Rules are read, but style is currently limited to font size, weight and color; and to fill style and color. +- Add ability to suppress Mac line ending check for CSV [#2623](https://github.com/PHPOffice/PhpSpreadsheet/pull/2623) + ### Changed - Gnumeric Reader now loads number formatting for cells. @@ -75,13 +77,17 @@ and this project adheres to [Semantic Versioning](https://semver.org). - Fix bug in Conditional Formatting in the Xls Writer that resulted in a broken file when there were multiple conditional ranges in a worksheet. - Fix Conditional Formatting in the Xls Writer to work with rules that contain string literals, cell references and formulae. - Fix for setting Active Sheet to the first loaded worksheet when bookViews element isn't defined [Issue #2666](https://github.com/PHPOffice/PhpSpreadsheet/issues/2666) [PR #2669](https://github.com/PHPOffice/PhpSpreadsheet/pull/2669) -- Fixed behaviour of XLSX font style vertical align settings. +- Fixed behaviour of XLSX font style vertical align settings [PR #2619](https://github.com/PHPOffice/PhpSpreadsheet/pull/2619) - Resolved formula translations to handle separators (row and column) for array functions as well as for function argument separators; and cleanly handle nesting levels. Note that this method is used when translating Excel functions between `en_us` and other locale languages, as well as when converting formulae between different spreadsheet formats (e.g. Ods to Excel). Nor is this a perfect solution, as there may still be issues when function calls have array arguments that themselves contain function calls; but it's still better than the current logic. - Fix for escaping double quotes within a formula [Issue #1971](https://github.com/PHPOffice/PhpSpreadsheet/issues/1971) [PR #2651](https://github.com/PHPOffice/PhpSpreadsheet/pull/2651) +- Change open mode for output from `wb+` to `wb` [Issue #2372](https://github.com/PHPOffice/PhpSpreadsheet/issues/2372) [PR #2657](https://github.com/PHPOffice/PhpSpreadsheet/pull/2657) +- Use color palette if supplied [Issue #2499](https://github.com/PHPOffice/PhpSpreadsheet/issues/2499) [PR #2595](https://github.com/PHPOffice/PhpSpreadsheet/pull/2595) +- Xls reader treat drawing offsets as int rather than float [PR #2648](https://github.com/PHPOffice/PhpSpreadsheet/pull/2648) +- Handle booleans in conditional styles properly [PR #2654](https://github.com/PHPOffice/PhpSpreadsheet/pull/2654) - Fix for reading files in the root directory of a ZipFile, which should not be prefixed by relative paths ("./") as dirname($filename) does by default. - Fix invalid style of cells in empty columns with columnDimensions and rows with rowDimensions in added external sheet. [PR #2739](https://github.com/PHPOffice/PhpSpreadsheet/pull/2739) From 76f486d8e323b97ea7b4c60ce2cc5f768b571fcf Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Mon, 18 Apr 2022 13:58:35 +0200 Subject: [PATCH 39/52] Initial work on supporting Freeze Pane for Ods Writer --- CHANGELOG.md | 1 + phpstan-baseline.neon | 5 - src/PhpSpreadsheet/Writer/Ods.php | 3 +- src/PhpSpreadsheet/Writer/Ods/Content.php | 4 +- src/PhpSpreadsheet/Writer/Ods/Settings.php | 108 ++++++++++++++++----- 5 files changed, 92 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e136eab6..331b2436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Added +- Ods Writer support for Freeze Pane [Issue #2013](https://github.com/PHPOffice/PhpSpreadsheet/issues/2013) [PR #2755](https://github.com/PHPOffice/PhpSpreadsheet/pull/2755) - Ods Writer support for setting column width/row height (including the use of AutoSize) [Issue #2346](https://github.com/PHPOffice/PhpSpreadsheet/issues/2346) [PR #2753](https://github.com/PHPOffice/PhpSpreadsheet/pull/2753) - Introduced CellAddress, CellRange, RowRange and ColumnRange value objects that can be used as an alternative to a string value (e.g. `'C5'`, `'B2:D4'`, `'2:2'` or `'B:C'`) in appropriate contexts. - Implementation of the FILTER(), SORT(), SORTBY() and UNIQUE() Lookup/Reference (array) functions. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c6d3dda9..42b9fe70 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -4690,11 +4690,6 @@ parameters: count: 1 path: src/PhpSpreadsheet/Writer/Ods/Formula.php - - - message: "#^Parameter \\#1 \\$content of method XMLWriter\\:\\:text\\(\\) expects string, int given\\.$#" - count: 2 - path: src/PhpSpreadsheet/Writer/Ods/Settings.php - - message: "#^Cannot call method getHashCode\\(\\) on PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\Font\\|null\\.$#" count: 1 diff --git a/src/PhpSpreadsheet/Writer/Ods.php b/src/PhpSpreadsheet/Writer/Ods.php index decd82bc..827c43b5 100644 --- a/src/PhpSpreadsheet/Writer/Ods.php +++ b/src/PhpSpreadsheet/Writer/Ods.php @@ -132,10 +132,11 @@ class Ods extends BaseWriter $zip->addFile('META-INF/manifest.xml', $this->getWriterPartMetaInf()->write()); $zip->addFile('Thumbnails/thumbnail.png', $this->getWriterPartthumbnails()->write()); + // Settings always need to be written before Content; Styles after Content + $zip->addFile('settings.xml', $this->getWriterPartsettings()->write()); $zip->addFile('content.xml', $this->getWriterPartcontent()->write()); $zip->addFile('meta.xml', $this->getWriterPartmeta()->write()); $zip->addFile('mimetype', $this->getWriterPartmimetype()->write()); - $zip->addFile('settings.xml', $this->getWriterPartsettings()->write()); $zip->addFile('styles.xml', $this->getWriterPartstyles()->write()); // Close file diff --git a/src/PhpSpreadsheet/Writer/Ods/Content.php b/src/PhpSpreadsheet/Writer/Ods/Content.php index 2cb31b36..5d227c84 100644 --- a/src/PhpSpreadsheet/Writer/Ods/Content.php +++ b/src/PhpSpreadsheet/Writer/Ods/Content.php @@ -294,7 +294,9 @@ class Content extends WriterPart $worksheet = $spreadsheet->getSheet($i); $worksheet->calculateColumnWidths(); foreach ($worksheet->getColumnDimensions() as $columnDimension) { - $styleWriter->writeColumnStyles($columnDimension, $i); + if ($columnDimension->getWidth() !== -1.0) { + $styleWriter->writeColumnStyles($columnDimension, $i); + } } } for ($i = 0; $i < $sheetCount; ++$i) { diff --git a/src/PhpSpreadsheet/Writer/Ods/Settings.php b/src/PhpSpreadsheet/Writer/Ods/Settings.php index 047bd410..06445591 100644 --- a/src/PhpSpreadsheet/Writer/Ods/Settings.php +++ b/src/PhpSpreadsheet/Writer/Ods/Settings.php @@ -2,8 +2,11 @@ namespace PhpOffice\PhpSpreadsheet\Writer\Ods; +use PhpOffice\PhpSpreadsheet\Cell\CellAddress; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Shared\XMLWriter; +use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; class Settings extends WriterPart { @@ -45,28 +48,9 @@ class Settings extends WriterPart $objWriter->text('view1'); $objWriter->endElement(); // ViewId $objWriter->startElement('config:config-item-map-named'); - $objWriter->writeAttribute('config:name', 'Tables'); - foreach ($spreadsheet->getWorksheetIterator() as $ws) { - $objWriter->startElement('config:config-item-map-entry'); - $objWriter->writeAttribute('config:name', $ws->getTitle()); - $selected = $ws->getSelectedCells(); - if (preg_match('/^([a-z]+)([0-9]+)/i', $selected, $matches) === 1) { - $colSel = Coordinate::columnIndexFromString($matches[1]) - 1; - $rowSel = (int) $matches[2] - 1; - $objWriter->startElement('config:config-item'); - $objWriter->writeAttribute('config:name', 'CursorPositionX'); - $objWriter->writeAttribute('config:type', 'int'); - $objWriter->text($colSel); - $objWriter->endElement(); - $objWriter->startElement('config:config-item'); - $objWriter->writeAttribute('config:name', 'CursorPositionY'); - $objWriter->writeAttribute('config:type', 'int'); - $objWriter->text($rowSel); - $objWriter->endElement(); - } - $objWriter->endElement(); // config:config-item-map-entry - } - $objWriter->endElement(); // config:config-item-map-named + + $this->writeAllWorksheetSettings($objWriter, $spreadsheet); + $wstitle = $spreadsheet->getActiveSheet()->getTitle(); $objWriter->startElement('config:config-item'); $objWriter->writeAttribute('config:name', 'ActiveTable'); @@ -85,4 +69,84 @@ class Settings extends WriterPart return $objWriter->getData(); } + + private function writeAllWorksheetSettings(XMLWriter $objWriter, Spreadsheet $spreadsheet): void + { + $objWriter->writeAttribute('config:name', 'Tables'); + + foreach ($spreadsheet->getWorksheetIterator() as $worksheet) { + $this->writeWorksheetSettings($objWriter, $worksheet); + } + + $objWriter->endElement(); // config:config-item-map-entry Tables + } + + private function writeWorksheetSettings(XMLWriter $objWriter, Worksheet $worksheet): void + { + $objWriter->startElement('config:config-item-map-entry'); + $objWriter->writeAttribute('config:name', $worksheet->getTitle()); + + $this->writeSelectedCells($objWriter, $worksheet); + if ($worksheet->getFreezePane() !== null) { + $this->writeFreezePane($objWriter, $worksheet); + } + + $objWriter->endElement(); // config:config-item-map-entry Worksheet + } + + private function writeSelectedCells(XMLWriter $objWriter, Worksheet $worksheet): void + { + $selected = $worksheet->getSelectedCells(); + if (preg_match('/^([a-z]+)([0-9]+)/i', $selected, $matches) === 1) { + $colSel = Coordinate::columnIndexFromString($matches[1]) - 1; + $rowSel = (int) $matches[2] - 1; + $objWriter->startElement('config:config-item'); + $objWriter->writeAttribute('config:name', 'CursorPositionX'); + $objWriter->writeAttribute('config:type', 'int'); + $objWriter->text((string) $colSel); + $objWriter->endElement(); + $objWriter->startElement('config:config-item'); + $objWriter->writeAttribute('config:name', 'CursorPositionY'); + $objWriter->writeAttribute('config:type', 'int'); + $objWriter->text((string) $rowSel); + $objWriter->endElement(); + } + } + + private function writeSplitValue(XMLWriter $objWriter, string $splitMode, string $type, string $value): void + { + $objWriter->startElement('config:config-item'); + $objWriter->writeAttribute('config:name', $splitMode); + $objWriter->writeAttribute('config:type', $type); + $objWriter->text($value); + $objWriter->endElement(); + } + + private function writeFreezePane(XMLWriter $objWriter, Worksheet $worksheet): void + { + $freezePane = CellAddress::fromCellAddress($worksheet->getFreezePane()); + if ($freezePane->cellAddress() === 'A1') { + return; + } + + $columnId = $freezePane->columnId(); + $columnName = $freezePane->columnName(); + $row = $freezePane->rowId(); + + $this->writeSplitValue($objWriter, 'HorizontalSplitMode', 'short', '2'); + $this->writeSplitValue($objWriter, 'HorizontalSplitPosition', 'int', (string) ($columnId - 1)); + $this->writeSplitValue($objWriter, 'PositionLeft', 'short', '0'); + $this->writeSplitValue($objWriter, 'PositionRight', 'short', (string) ($columnId - 1)); + + for ($column = 'A'; $column !== $columnName; ++$column) { + $worksheet->getColumnDimension($column)->setAutoSize(true); + } + + $this->writeSplitValue($objWriter, 'VerticalSplitMode', 'short', '2'); + $this->writeSplitValue($objWriter, 'VerticalSplitPosition', 'int', (string) ($row - 1)); + $this->writeSplitValue($objWriter, 'PositionTop', 'short', '0'); + $this->writeSplitValue($objWriter, 'PositionBottom', 'short', (string) ($row - 1)); + + $this->writeSplitValue($objWriter, 'ActiveSplitRange', 'short', '3'); + } } From 4a65011a2f394e5bc3984bd0f5da41a732661572 Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Tue, 19 Apr 2022 14:00:57 +0200 Subject: [PATCH 40/52] Allow Reader format identification to use a subset of possible Readers --- docs/topics/file-formats.md | 7 +- docs/topics/reading-files.md | 66 +++++++++++++--- src/PhpSpreadsheet/IOFactory.php | 88 +++++++++++++++------ src/PhpSpreadsheet/Reader/BaseReader.php | 4 + tests/PhpSpreadsheetTests/IOFactoryTest.php | 16 ++++ 5 files changed, 144 insertions(+), 37 deletions(-) diff --git a/docs/topics/file-formats.md b/docs/topics/file-formats.md index 6f8783e6..7318b136 100644 --- a/docs/topics/file-formats.md +++ b/docs/topics/file-formats.md @@ -80,7 +80,8 @@ semi-colon (`;`) are used as separators instead of a comma, although other symbols can be used. Because CSV is a text-only format, it doesn't support any data formatting options. -"CSV" is not a single, well-defined format (although see RFC 4180 for +"CSV" is not a single, well-defined format (although see +[RFC 4180](https://www.rfc-editor.org/rfc/rfc4180.html) for one definition that is commonly used). Rather, in practice the term "CSV" refers to any file that: @@ -117,5 +118,5 @@ Wide Web Consortium (W3C). However, in 2000, HTML also became an international standard (ISO/IEC 15445:2000). HTML 4.01 was published in late 1999, with further errata published through 2001. In 2004 development began on HTML5 in the Web Hypertext Application Technology -Working Group (WHATWG), which became a joint deliverable with the W3C in -2008. +Working Group (WHATWG), which became a joint deliverable with the W3C in 2008. + diff --git a/docs/topics/reading-files.md b/docs/topics/reading-files.md index 38428166..76705281 100644 --- a/docs/topics/reading-files.md +++ b/docs/topics/reading-files.md @@ -44,6 +44,22 @@ practise), it will reject the Xls loader that it would normally use for a .xls file; and test the file using the other loaders until it finds the appropriate loader, and then use that to read the file. +If you know that this is an `xls` file, but don't know whether it is a +genuine BIFF-format Excel or Html markup with an xls extension, you can +limit the loader to check only those two possibilities by passing in an +array of Readers to test against. + +```php +$inputFileName = './sampleData/example1.xls'; +$testAgainstFormats = [ + \PhpOffice\PhpSpreadsheet\IOFactory::READER_XLS, + \PhpOffice\PhpSpreadsheet\IOFactory::READER_HTML, +]; + +/** Load $inputFileName to a Spreadsheet Object **/ +$spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($inputFileName, 0, $testAgainstFormats); +``` + While easy to implement in your code, and you don't need to worry about the file type; this isn't the most efficient method to load a file; and it lacks the flexibility to configure the loader in any way before @@ -118,6 +134,34 @@ $spreadsheet = $reader->load($inputFileName); See `samples/Reader/04_Simple_file_reader_using_the_IOFactory_to_identify_a_reader_to_use.php` for a working example of this code. +As with the IOFactory `load()` method, you can also pass an array of formats +for the `identify()` method to check against if you know that it will only +be in a subset of the possible formats that PhpSpreadsheet supports. + +```php +$inputFileName = './sampleData/example1.xls'; +$testAgainstFormats = [ + \PhpOffice\PhpSpreadsheet\IOFactory::READER_XLS, + \PhpOffice\PhpSpreadsheet\IOFactory::READER_HTML, +]; + +/** Identify the type of $inputFileName **/ +$inputFileType = \PhpOffice\PhpSpreadsheet\IOFactory::identify($inputFileName, $testAgainstFormats); +``` + +You can also use this to confirm that a file is what it claims to be: + +```php +$inputFileName = './sampleData/example1.xls'; + +try { + /** Verify that $inputFileName really is an Xls file **/ + $inputFileType = \PhpOffice\PhpSpreadsheet\IOFactory::identify($inputFileName, [\PhpOffice\PhpSpreadsheet\IOFactory::READER_XLS]); +} catch (\PhpOffice\PhpSpreadsheet\Reader\Exception $e) { + // File isn't actually an Xls file, even though it has an xls extension +} +``` + ## Spreadsheet Reader Options Once you have created a reader object for the workbook that you want to @@ -146,7 +190,7 @@ $spreadsheet = $reader->load($inputFileName); See `samples/Reader/05_Simple_file_reader_using_the_read_data_only_option.php` for a working example of this code. -It is important to note that Workbooks (and PhpSpreadsheet) store dates +It is important to note that most Workbooks (and PhpSpreadsheet) store dates and times as simple numeric values: they can only be distinguished from other numeric values by the format mask that is applied to that cell. When setting read data only to true, PhpSpreadsheet doesn't read the @@ -162,8 +206,8 @@ Reading Only Data from a Spreadsheet File applies to Readers: Reader | Y/N |Reader | Y/N |Reader | Y/N | ----------|:---:|--------|:---:|--------------|:---:| -Xlsx | YES | Xls | YES | Xml | YES | -Ods | YES | SYLK | NO | Gnumeric | YES | +Xlsx | YES | Xls | YES | Xml | YES | +Ods | YES | SYLK | NO | Gnumeric | YES | CSV | NO | HTML | NO ### Reading Only Named WorkSheets from a File @@ -233,8 +277,8 @@ Reading Only Named WorkSheets from a File applies to Readers: Reader | Y/N |Reader | Y/N |Reader | Y/N | ----------|:---:|--------|:---:|--------------|:---:| -Xlsx | YES | Xls | YES | Xml | YES | -Ods | YES | SYLK | NO | Gnumeric | YES | +Xlsx | YES | Xls | YES | Xml | YES | +Ods | YES | SYLK | NO | Gnumeric | YES | CSV | NO | HTML | NO ### Reading Only Specific Columns and Rows from a File (Read Filters) @@ -381,7 +425,7 @@ Using Read Filters applies to: Reader | Y/N |Reader | Y/N |Reader | Y/N | ----------|:---:|--------|:---:|--------------|:---:| -Xlsx | YES | Xls | YES | Xml | YES | +Xlsx | YES | Xls | YES | Xml | YES | Ods | YES | SYLK | NO | Gnumeric | YES | CSV | YES | HTML | NO | | | @@ -439,7 +483,7 @@ Combining Multiple Files into a Single Spreadsheet Object applies to: Reader | Y/N |Reader | Y/N |Reader | Y/N | ----------|:---:|--------|:---:|--------------|:---:| -Xlsx | NO | Xls | NO | Xml | NO | +Xlsx | NO | Xls | NO | Xml | NO | Ods | NO | SYLK | YES | Gnumeric | NO | CSV | YES | HTML | NO @@ -516,7 +560,7 @@ Splitting a single loaded file across multiple worksheets applies to: Reader | Y/N |Reader | Y/N |Reader | Y/N | ----------|:---:|--------|:---:|--------------|:---:| -Xlsx | NO | Xls | NO | Xml | NO | +Xlsx | NO | Xls | NO | Xml | NO | Ods | NO | SYLK | NO | Gnumeric | NO | CSV | YES | HTML | NO @@ -556,7 +600,7 @@ Setting CSV delimiter applies to: Reader | Y/N |Reader | Y/N |Reader | Y/N | ----------|:---:|--------|:---:|--------------|:---:| -Xlsx | NO | Xls | NO | Xml | NO | +Xlsx | NO | Xls | NO | Xml | NO | Ods | NO | SYLK | NO | Gnumeric | NO | CSV | YES | HTML | NO @@ -594,7 +638,7 @@ Applies to: Reader | Y/N |Reader | Y/N |Reader | Y/N | ----------|:---:|--------|:---:|--------------|:---:| -Xlsx | NO | Xls | NO | Xml | NO | +Xlsx | NO | Xls | NO | Xml | NO | Ods | NO | SYLK | NO | Gnumeric | NO | CSV | YES | HTML | NO @@ -646,7 +690,7 @@ Loading using a Value Binder applies to: Reader | Y/N |Reader | Y/N |Reader | Y/N ----------|:---:|--------|:---:|--------------|:---: -Xlsx | NO | Xls | NO | Xml | NO +Xlsx | NO | Xls | NO | Xml | NO Ods | NO | SYLK | NO | Gnumeric | NO CSV | YES | HTML | YES diff --git a/src/PhpSpreadsheet/IOFactory.php b/src/PhpSpreadsheet/IOFactory.php index 91613cb4..e437a220 100644 --- a/src/PhpSpreadsheet/IOFactory.php +++ b/src/PhpSpreadsheet/IOFactory.php @@ -14,23 +14,39 @@ use PhpOffice\PhpSpreadsheet\Writer\IWriter; */ abstract class IOFactory { + public const READER_XLSX = 'Xlsx'; + public const READER_XLS = 'Xls'; + public const READER_XML = 'Xml'; + public const READER_ODS = 'Ods'; + public const READER_SYLK = 'Slk'; + public const READER_SLK = 'Slk'; + public const READER_GNUMERIC = 'Gnumeric'; + public const READER_HTML = 'Html'; + public const READER_CSV = 'Csv'; + + public const WRITER_XLSX = 'Xlsx'; + public const WRITER_XLS = 'Xls'; + public const WRITER_ODS = 'Ods'; + public const WRITER_CSV = 'Csv'; + public const WRITER_HTML = 'Html'; + private static $readers = [ - 'Xlsx' => Reader\Xlsx::class, - 'Xls' => Reader\Xls::class, - 'Xml' => Reader\Xml::class, - 'Ods' => Reader\Ods::class, - 'Slk' => Reader\Slk::class, - 'Gnumeric' => Reader\Gnumeric::class, - 'Html' => Reader\Html::class, - 'Csv' => Reader\Csv::class, + self::READER_XLSX => Reader\Xlsx::class, + self::READER_XLS => Reader\Xls::class, + self::READER_XML => Reader\Xml::class, + self::READER_ODS => Reader\Ods::class, + self::READER_SLK => Reader\Slk::class, + self::READER_GNUMERIC => Reader\Gnumeric::class, + self::READER_HTML => Reader\Html::class, + self::READER_CSV => Reader\Csv::class, ]; private static $writers = [ - 'Xls' => Writer\Xls::class, - 'Xlsx' => Writer\Xlsx::class, - 'Ods' => Writer\Ods::class, - 'Csv' => Writer\Csv::class, - 'Html' => Writer\Html::class, + self::WRITER_XLS => Writer\Xls::class, + self::WRITER_XLSX => Writer\Xlsx::class, + self::WRITER_ODS => Writer\Ods::class, + self::WRITER_CSV => Writer\Csv::class, + self::WRITER_HTML => Writer\Html::class, 'Tcpdf' => Writer\Pdf\Tcpdf::class, 'Dompdf' => Writer\Pdf\Dompdf::class, 'Mpdf' => Writer\Pdf\Mpdf::class, @@ -70,10 +86,18 @@ abstract class IOFactory * Loads Spreadsheet from file using automatic Reader\IReader resolution. * * @param string $filename The name of the spreadsheet file + * @param int $flags the optional second parameter flags may be used to identify specific elements + * that should be loaded, but which won't be loaded by default, using these values: + * IReader::LOAD_WITH_CHARTS - Include any charts that are defined in the loaded file + * @param string[] $readers An array of Readers to use to identify the file type. By default, load() will try + * all possible Readers until it finds a match; but this allows you to pass in a + * list of Readers so it will only try the subset that you specify here. + * Values in this list can be any of the constant values defined in the set + * IOFactory::READER_*. */ - public static function load(string $filename, int $flags = 0): Spreadsheet + public static function load(string $filename, int $flags = 0, ?array $readers = null): Spreadsheet { - $reader = self::createReaderForFile($filename); + $reader = self::createReaderForFile($filename, $readers); return $reader->load($filename, $flags); } @@ -81,9 +105,9 @@ abstract class IOFactory /** * Identify file type using automatic IReader resolution. */ - public static function identify(string $filename): string + public static function identify(string $filename, ?array $readers = null): string { - $reader = self::createReaderForFile($filename); + $reader = self::createReaderForFile($filename, $readers); $className = get_class($reader); $classType = explode('\\', $className); unset($reader); @@ -93,14 +117,32 @@ abstract class IOFactory /** * Create Reader\IReader for file using automatic IReader resolution. + * + * @param string[] $readers An array of Readers to use to identify the file type. By default, load() will try + * all possible Readers until it finds a match; but this allows you to pass in a + * list of Readers so it will only try the subset that you specify here. + * Values in this list can be any of the constant values defined in the set + * IOFactory::READER_*. */ - public static function createReaderForFile(string $filename): IReader + public static function createReaderForFile(string $filename, ?array $readers = null): IReader { File::assertFile($filename); + $testReaders = self::$readers; + if ($readers !== null) { + $readers = array_map('strtoupper', $readers); + $testReaders = array_filter( + self::$readers, + function (string $readerType) use ($readers) { + return in_array(strtoupper($readerType), $readers, true); + }, + ARRAY_FILTER_USE_KEY + ); + } + // First, lucky guess by inspecting file extension $guessedReader = self::getReaderTypeFromExtension($filename); - if ($guessedReader !== null) { + if (($guessedReader !== null) && array_key_exists($guessedReader, $testReaders)) { $reader = self::createReader($guessedReader); // Let's see if we are lucky @@ -110,11 +152,11 @@ abstract class IOFactory } // If we reach here then "lucky guess" didn't give any result - // Try walking through all the options in self::$autoResolveClasses - foreach (self::$readers as $type => $class) { + // Try walking through all the options in self::$readers (or the selected subset) + foreach ($testReaders as $readerType => $class) { // Ignore our original guess, we know that won't work - if ($type !== $guessedReader) { - $reader = self::createReader($type); + if ($readerType !== $guessedReader) { + $reader = self::createReader($readerType); if ($reader->canRead($filename)) { return $reader; } diff --git a/src/PhpSpreadsheet/Reader/BaseReader.php b/src/PhpSpreadsheet/Reader/BaseReader.php index c215e65b..a137e78c 100644 --- a/src/PhpSpreadsheet/Reader/BaseReader.php +++ b/src/PhpSpreadsheet/Reader/BaseReader.php @@ -153,6 +153,10 @@ abstract class BaseReader implements IReader /** * Loads Spreadsheet from file. + * + * @param int $flags the optional second parameter flags may be used to identify specific elements + * that should be loaded, but which won't be loaded by default, using these values: + * IReader::LOAD_WITH_CHARTS - Include any charts that are defined in the loaded file */ public function load(string $filename, int $flags = 0): Spreadsheet { diff --git a/tests/PhpSpreadsheetTests/IOFactoryTest.php b/tests/PhpSpreadsheetTests/IOFactoryTest.php index b516a9f4..19722dc7 100644 --- a/tests/PhpSpreadsheetTests/IOFactoryTest.php +++ b/tests/PhpSpreadsheetTests/IOFactoryTest.php @@ -108,6 +108,22 @@ class IOFactoryTest extends TestCase ]; } + public function testFormatAsExpected(): void + { + $fileName = 'samples/templates/30template.xls'; + + $actual = IOFactory::identify($fileName, [IOFactory::READER_XLS]); + self::assertSame('Xls', $actual); + } + + public function testFormatNotAsExpectedThrowsException(): void + { + $fileName = 'samples/templates/30template.xls'; + + $this->expectException(ReaderException::class); + IOFactory::identify($fileName, [IOFactory::READER_ODS]); + } + public function testIdentifyNonExistingFileThrowException(): void { $this->expectException(ReaderException::class); From 9275d0c59e3122a91da864f902167f1d08bc87fa Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Tue, 19 Apr 2022 16:42:34 +0200 Subject: [PATCH 41/52] Improved documentation for setting row height/column width --- docs/topics/recipes.md | 18 ++++++++++++++++-- .../Worksheet/ColumnDimension.php | 15 +++++++++------ src/PhpSpreadsheet/Worksheet/RowDimension.php | 9 +++++---- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/docs/topics/recipes.md b/docs/topics/recipes.md index 404f8823..f25a9119 100644 --- a/docs/topics/recipes.md +++ b/docs/topics/recipes.md @@ -1167,13 +1167,15 @@ that you are setting is measured in. Valid units are `pt` (points), `px` (pixels), `pc` (pica), `in` (inches), `cm` (centimeters) and `mm` (millimeters). +Setting the column width to `-1` tells MS Excel to display the column using its default width. + ```php $spreadsheet->getActiveSheet()->getColumnDimension('D')->setWidth(120, 'pt'); ``` If you want PhpSpreadsheet to perform an automatic width calculation, -use the following code. PhpSpreadsheet will approximate the column with -to the width of the widest column value. +use the following code. PhpSpreadsheet will approximate the column width +to the width of the widest value displayed in that column. ```php $spreadsheet->getActiveSheet()->getColumnDimension('B')->setAutoSize(true); @@ -1266,6 +1268,18 @@ Valid units are `pt` (points), `px` (pixels), `pc` (pica), `in` (inches), $spreadsheet->getActiveSheet()->getRowDimension('10')->setRowHeight(100, 'pt'); ``` +Setting the row height to `-1` tells MS Excel to display the column using its default height, which is based on the character font size. + +If you have wrapped text in a cell, then the `-1` default will only set the row height to display a single line of that wrapped text. +If you need to calculate the actual height for the row, then count the lines that should be displayed (count the `\n` and add 1); then adjust for the font. +The adjustment for Calibri 11 is approximately 14.5; for Calibri 12 15.9, etc. +```php +$spreadsheet->getActiveSheet()->getRowDimension(1)->setRowHeight( + 14.5 * (substr_count($sheet->getCell('A1')->getValue(), "\n") + 1) +); +``` + + ## Show/hide a row To set a worksheet''s row visibility, you can use the following code. diff --git a/src/PhpSpreadsheet/Worksheet/ColumnDimension.php b/src/PhpSpreadsheet/Worksheet/ColumnDimension.php index 17dd261b..b64ecec9 100644 --- a/src/PhpSpreadsheet/Worksheet/ColumnDimension.php +++ b/src/PhpSpreadsheet/Worksheet/ColumnDimension.php @@ -83,9 +83,10 @@ class ColumnDimension extends Dimension /** * Get Width. * - * Each unit of column width is equal to the width of one character in the default font size. - * By default, this will be the return value; but this method also accepts a unit of measure argument and will - * return the value converted to the specified UoM using an approximation method. + * Each unit of column width is equal to the width of one character in the default font size. A value of -1 + * tells Excel to display this column in its default width. + * By default, this will be the return value; but this method also accepts an optional unit of measure argument + * and will convert the returned value to the specified UoM.. */ public function getWidth(?string $unitOfMeasure = null): float { @@ -97,9 +98,11 @@ class ColumnDimension extends Dimension /** * Set Width. * - * Each unit of column width is equal to the width of one character in the default font size. - * By default, this will be the unit of measure for the passed value; but this method accepts a unit of measure - * argument, and will convert the value from the specified UoM using an approximation method. + * Each unit of column width is equal to the width of one character in the default font size. A value of -1 + * tells Excel to display this column in its default width. + * By default, this will be the unit of measure for the passed value; but this method also accepts an + * optional unit of measure argument, and will convert the value from the specified UoM using an + * approximation method. * * @return $this */ diff --git a/src/PhpSpreadsheet/Worksheet/RowDimension.php b/src/PhpSpreadsheet/Worksheet/RowDimension.php index 1d8aada8..acaafac5 100644 --- a/src/PhpSpreadsheet/Worksheet/RowDimension.php +++ b/src/PhpSpreadsheet/Worksheet/RowDimension.php @@ -65,8 +65,9 @@ class RowDimension extends Dimension /** * Get Row Height. - * By default, this will be in points; but this method accepts a unit of measure - * argument, and will convert the value to the specified UoM. + * By default, this will be in points; but this method also accepts an optional unit of measure + * argument, and will convert the value from points to the specified UoM. + * A value of -1 tells Excel to display this column in its default height. * * @return float */ @@ -80,8 +81,8 @@ class RowDimension extends Dimension /** * Set Row Height. * - * @param float $height in points - * By default, this will be the passed argument value; but this method accepts a unit of measure + * @param float $height in points. A value of -1 tells Excel to display this column in its default height. + * By default, this will be the passed argument value; but this method also accepts an optional unit of measure * argument, and will convert the passed argument value to points from the specified UoM * * @return $this From ad56616309700fcd09543126227307b28110e03b Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Wed, 20 Apr 2022 20:01:45 +0200 Subject: [PATCH 42/52] Reduce size of Unique ID Prefix used for the Cell Collection when "in memory" (reduces the per-cell memory overhead, while still retaining a unique prefix to ensure no clash between worksheet collections). External cache (where multiple threads may be accessing the same cache with different workeets) still uses the same length and entropy in the prefix as before --- src/PhpSpreadsheet/Collection/Cells.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/PhpSpreadsheet/Collection/Cells.php b/src/PhpSpreadsheet/Collection/Cells.php index e3d81cb4..3ffb8a4f 100644 --- a/src/PhpSpreadsheet/Collection/Cells.php +++ b/src/PhpSpreadsheet/Collection/Cells.php @@ -6,6 +6,7 @@ use Generator; use PhpOffice\PhpSpreadsheet\Cell\Cell; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Exception as PhpSpreadsheetException; +use PhpOffice\PhpSpreadsheet\Settings; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use Psr\SimpleCache\CacheInterface; @@ -298,7 +299,9 @@ class Cells */ private function getUniqueID() { - return uniqid('phpspreadsheet.', true) . '.'; + return Settings::getCache() instanceof Memory + ? random_bytes(7) . ':' + : uniqid('phpspreadsheet.', true) . '.'; } /** From b5e11b130778dd0d48b17043f52ccb65930463b4 Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Thu, 21 Apr 2022 11:22:40 +0200 Subject: [PATCH 43/52] Performance tweaks to cell collection --- src/PhpSpreadsheet/Collection/Cells.php | 44 ++++++++++--------------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/src/PhpSpreadsheet/Collection/Cells.php b/src/PhpSpreadsheet/Collection/Cells.php index 3ffb8a4f..a29bdb59 100644 --- a/src/PhpSpreadsheet/Collection/Cells.php +++ b/src/PhpSpreadsheet/Collection/Cells.php @@ -172,9 +172,9 @@ class Cells // Lookup highest column and highest row $col = ['A' => '1A']; $row = [1]; + $c = ''; + $r = 0; foreach ($this->getCoordinates() as $coord) { - $c = ''; - $r = 0; sscanf($coord, '%[A-Z]%d', $c, $r); $row[$r] = $r; $col[$c] = strlen($c) . $c; @@ -241,24 +241,21 @@ class Cells public function getHighestColumn($row = null) { if ($row === null) { - $colRow = $this->getHighestRowAndColumn(); - - return $colRow['column']; + return $this->getHighestRowAndColumn()['column']; } - $columnList = [1]; + $maxColumn = '1A'; + $c = ''; + $r = 0; foreach ($this->getCoordinates() as $coord) { - $c = ''; - $r = 0; - sscanf($coord, '%[A-Z]%d', $c, $r); if ($r != $row) { continue; } - $columnList[] = Coordinate::columnIndexFromString($c); + $maxColumn = max($maxColumn, strlen($c) . $c); } - return Coordinate::stringFromColumnIndex((int) @max($columnList)); + return substr($maxColumn, 1); } /** @@ -272,24 +269,21 @@ class Cells public function getHighestRow($column = null) { if ($column === null) { - $colRow = $this->getHighestRowAndColumn(); - - return $colRow['row']; + return $this->getHighestRowAndColumn()['row']; } - $rowList = [0]; + $maxRow = 1; + $c = ''; + $r = 0; foreach ($this->getCoordinates() as $coord) { - $c = ''; - $r = 0; - sscanf($coord, '%[A-Z]%d', $c, $r); if ($c != $column) { continue; } - $rowList[] = $r; + $maxRow = max($maxRow, $r); } - return max($rowList); + return $maxRow; } /** @@ -347,10 +341,9 @@ class Cells */ public function removeRow($row): void { + $c = ''; + $r = 0; foreach ($this->getCoordinates() as $coord) { - $c = ''; - $r = 0; - sscanf($coord, '%[A-Z]%d', $c, $r); if ($r == $row) { $this->delete($coord); @@ -365,10 +358,9 @@ class Cells */ public function removeColumn($column): void { + $c = ''; + $r = 0; foreach ($this->getCoordinates() as $coord) { - $c = ''; - $r = 0; - sscanf($coord, '%[A-Z]%d', $c, $r); if ($c == $column) { $this->delete($coord); From 8126e24faf1ec7254a91d1e4d56a5e82e7df3c2c Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Fri, 22 Apr 2022 15:40:55 +0200 Subject: [PATCH 44/52] Performance tweaks to cell collection I suspect that scrutiniser may complain that the pass-by-reference values in an expression like `sscanf($coord, '%[A-Z]%d', $column, $row)` don't exist; but PHP handles that without issue, creating the variables as needed, and phpstan has no problems with that, so scrutiniser shouldn't treat it as an issue. There's no point in adding code (even if it's just pre-defining call-by-reference arguments) when it's unnecessary overhead. --- phpstan-baseline.neon | 10 ---------- src/PhpSpreadsheet/Cell/Coordinate.php | 2 +- src/PhpSpreadsheet/Collection/Cells.php | 19 ------------------- src/PhpSpreadsheet/ReferenceHelper.php | 8 ++++---- 4 files changed, 5 insertions(+), 34 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 42b9fe70..e7c917ec 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1175,11 +1175,6 @@ parameters: count: 1 path: src/PhpSpreadsheet/Cell/Coordinate.php - - - message: "#^Cannot use array destructuring on array\\|null\\.$#" - count: 1 - path: src/PhpSpreadsheet/Cell/Coordinate.php - - message: "#^Parameter \\#4 \\$currentRow of static method PhpOffice\\\\PhpSpreadsheet\\\\Cell\\\\Coordinate\\:\\:validateRange\\(\\) expects int, string given\\.$#" count: 1 @@ -3120,11 +3115,6 @@ parameters: count: 1 path: src/PhpSpreadsheet/Reader/Xml/Style.php - - - message: "#^Cannot use array destructuring on array\\|null\\.$#" - count: 4 - path: src/PhpSpreadsheet/ReferenceHelper.php - - message: "#^Elseif condition is always true\\.$#" count: 1 diff --git a/src/PhpSpreadsheet/Cell/Coordinate.php b/src/PhpSpreadsheet/Cell/Coordinate.php index 703eac08..ea53919c 100644 --- a/src/PhpSpreadsheet/Cell/Coordinate.php +++ b/src/PhpSpreadsheet/Cell/Coordinate.php @@ -383,7 +383,7 @@ abstract class Coordinate // Sort the result by column and row $sortKeys = []; foreach ($cellList as $coord) { - [$column, $row] = sscanf($coord, '%[A-Z]%d'); + sscanf($coord, '%[A-Z]%d', $column, $row); $sortKeys[sprintf('%3s%09d', $column, $row)] = $coord; } ksort($sortKeys); diff --git a/src/PhpSpreadsheet/Collection/Cells.php b/src/PhpSpreadsheet/Collection/Cells.php index a29bdb59..d49f5457 100644 --- a/src/PhpSpreadsheet/Collection/Cells.php +++ b/src/PhpSpreadsheet/Collection/Cells.php @@ -4,7 +4,6 @@ namespace PhpOffice\PhpSpreadsheet\Collection; use Generator; use PhpOffice\PhpSpreadsheet\Cell\Cell; -use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Exception as PhpSpreadsheetException; use PhpOffice\PhpSpreadsheet\Settings; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; @@ -152,8 +151,6 @@ class Cells { $sortKeys = []; foreach ($this->getCoordinates() as $coord) { - $column = ''; - $row = 0; sscanf($coord, '%[A-Z]%d', $column, $row); $sortKeys[sprintf('%09d%3s', $row, $column)] = $coord; } @@ -172,8 +169,6 @@ class Cells // Lookup highest column and highest row $col = ['A' => '1A']; $row = [1]; - $c = ''; - $r = 0; foreach ($this->getCoordinates() as $coord) { sscanf($coord, '%[A-Z]%d', $c, $r); $row[$r] = $r; @@ -207,9 +202,6 @@ class Cells */ public function getCurrentColumn() { - $column = ''; - $row = 0; - sscanf($this->currentCoordinate ?? '', '%[A-Z]%d', $column, $row); return $column; @@ -222,9 +214,6 @@ class Cells */ public function getCurrentRow() { - $column = ''; - $row = 0; - sscanf($this->currentCoordinate ?? '', '%[A-Z]%d', $column, $row); return (int) $row; @@ -245,8 +234,6 @@ class Cells } $maxColumn = '1A'; - $c = ''; - $r = 0; foreach ($this->getCoordinates() as $coord) { sscanf($coord, '%[A-Z]%d', $c, $r); if ($r != $row) { @@ -273,8 +260,6 @@ class Cells } $maxRow = 1; - $c = ''; - $r = 0; foreach ($this->getCoordinates() as $coord) { sscanf($coord, '%[A-Z]%d', $c, $r); if ($c != $column) { @@ -341,8 +326,6 @@ class Cells */ public function removeRow($row): void { - $c = ''; - $r = 0; foreach ($this->getCoordinates() as $coord) { sscanf($coord, '%[A-Z]%d', $c, $r); if ($r == $row) { @@ -358,8 +341,6 @@ class Cells */ public function removeColumn($column): void { - $c = ''; - $r = 0; foreach ($this->getCoordinates() as $coord) { sscanf($coord, '%[A-Z]%d', $c, $r); if ($c == $column) { diff --git a/src/PhpSpreadsheet/ReferenceHelper.php b/src/PhpSpreadsheet/ReferenceHelper.php index 665b2e18..c5f57792 100644 --- a/src/PhpSpreadsheet/ReferenceHelper.php +++ b/src/PhpSpreadsheet/ReferenceHelper.php @@ -90,8 +90,8 @@ class ReferenceHelper */ public static function cellSort($a, $b) { - [$ac, $ar] = sscanf($a, '%[A-Z]%d'); - [$bc, $br] = sscanf($b, '%[A-Z]%d'); + sscanf($a, '%[A-Z]%d', $ac, $ar); + sscanf($b, '%[A-Z]%d', $bc, $br); if ($ar === $br) { return strcasecmp(strlen($ac) . $ac, strlen($bc) . $bc); @@ -111,8 +111,8 @@ class ReferenceHelper */ public static function cellReverseSort($a, $b) { - [$ac, $ar] = sscanf($a, '%[A-Z]%d'); - [$bc, $br] = sscanf($b, '%[A-Z]%d'); + sscanf($a, '%[A-Z]%d', $ac, $ar); + sscanf($b, '%[A-Z]%d', $bc, $br); if ($ar === $br) { return -strcasecmp(strlen($ac) . $ac, strlen($bc) . $bc); From 69edf61ed652595acfa8b38e994e811ad55003a2 Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Sat, 23 Apr 2022 13:08:06 +0200 Subject: [PATCH 45/52] Extract cell/range validations from Worksheet and move into a separate Worksheet/Validations class as public static methods Extract tryDefinedName logic from Worksheet, nd move into the Worksheet/Validations class as a public static method Apply stricter validation to autofilter range arguments --- src/PhpSpreadsheet/Calculation/Functions.php | 8 +- src/PhpSpreadsheet/Worksheet/AutoFilter.php | 23 ++- src/PhpSpreadsheet/Worksheet/Validations.php | 100 +++++++++++++ src/PhpSpreadsheet/Worksheet/Worksheet.php | 142 +++---------------- 4 files changed, 142 insertions(+), 131 deletions(-) create mode 100644 src/PhpSpreadsheet/Worksheet/Validations.php diff --git a/src/PhpSpreadsheet/Calculation/Functions.php b/src/PhpSpreadsheet/Calculation/Functions.php index 00d8a790..dc6ee82e 100644 --- a/src/PhpSpreadsheet/Calculation/Functions.php +++ b/src/PhpSpreadsheet/Calculation/Functions.php @@ -4,7 +4,6 @@ namespace PhpOffice\PhpSpreadsheet\Calculation; use PhpOffice\PhpSpreadsheet\Cell\Cell; use PhpOffice\PhpSpreadsheet\Shared\Date; -use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; class Functions { @@ -686,12 +685,13 @@ class Functions // Uppercase coordinate $pCoordinatex = strtoupper($coordinate); // Eliminate leading equal sign - $pCoordinatex = Worksheet::pregReplace('/^=/', '', $pCoordinatex); + $pCoordinatex = (string) preg_replace('/^=/', '', $pCoordinatex); $defined = $spreadsheet->getDefinedName($pCoordinatex, $worksheet); if ($defined !== null) { $worksheet2 = $defined->getWorkSheet(); if (!$defined->isFormula() && $worksheet2 !== null) { - $coordinate = "'" . $worksheet2->getTitle() . "'!" . Worksheet::pregReplace('/^=/', '', $defined->getValue()); + $coordinate = "'" . $worksheet2->getTitle() . "'!" . + (string) preg_replace('/^=/', '', $defined->getValue()); } } @@ -700,7 +700,7 @@ class Functions public static function trimTrailingRange(string $coordinate): string { - return Worksheet::pregReplace('/:[\\w\$]+$/', '', $coordinate); + return (string) preg_replace('/:[\\w\$]+$/', '', $coordinate); } public static function trimSheetFromCellReference(string $coordinate): string diff --git a/src/PhpSpreadsheet/Worksheet/AutoFilter.php b/src/PhpSpreadsheet/Worksheet/AutoFilter.php index 2e6c8289..dd33d5d5 100644 --- a/src/PhpSpreadsheet/Worksheet/AutoFilter.php +++ b/src/PhpSpreadsheet/Worksheet/AutoFilter.php @@ -7,6 +7,7 @@ use DateTimeZone; use PhpOffice\PhpSpreadsheet\Calculation\Calculation; use PhpOffice\PhpSpreadsheet\Calculation\Functions; use PhpOffice\PhpSpreadsheet\Calculation\Internal\WildcardMatch; +use PhpOffice\PhpSpreadsheet\Cell\AddressRange; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Exception as PhpSpreadsheetException; use PhpOffice\PhpSpreadsheet\Shared\Date; @@ -50,9 +51,18 @@ class AutoFilter /** * Create a new AutoFilter. + * + * @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 __construct(string $range = '', ?Worksheet $worksheet = null) + public function __construct($range = '', ?Worksheet $worksheet = null) { + if ($range !== '') { + [, $range] = Worksheet::extractSheetTitle(Validations::validateCellRange($range), true); + } + $this->range = $range; $this->workSheet = $worksheet; } @@ -92,12 +102,19 @@ class AutoFilter /** * Set AutoFilter 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(string $range): self + public function setRange($range = ''): self { $this->evaluated = false; // extract coordinate - [$worksheet, $range] = Worksheet::extractSheetTitle($range, true); + if ($range !== '') { + [, $range] = Worksheet::extractSheetTitle(Validations::validateCellRange($range), true); + } if (empty($range)) { // Discard all column rules $this->columns = []; diff --git a/src/PhpSpreadsheet/Worksheet/Validations.php b/src/PhpSpreadsheet/Worksheet/Validations.php new file mode 100644 index 00000000..b31b0813 --- /dev/null +++ b/src/PhpSpreadsheet/Worksheet/Validations.php @@ -0,0 +1,100 @@ +|CellAddress|string $cellAddress Coordinate of the cell as a string, eg: 'C5'; + * or as an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object. + */ + public static function validateCellAddress($cellAddress): string + { + if (is_string($cellAddress)) { + [$worksheet, $address] = Worksheet::extractSheetTitle($cellAddress, true); +// if (!empty($worksheet) && $worksheet !== $this->getTitle()) { +// throw new Exception('Reference is not for this worksheet'); +// } + + return empty($worksheet) ? strtoupper($address) : $worksheet . '!' . strtoupper($address); + } + + if (is_array($cellAddress)) { + $cellAddress = CellAddress::fromColumnRowArray($cellAddress); + } + + return (string) $cellAddress; + } + + /** + * Validate a cell address or cell range. + * + * @param AddressRange|array|CellAddress|int|string $cellRange Coordinate of the cells as a string, eg: 'C5:F12'; + * or as an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 12]), + * or as a CellAddress or AddressRange object. + */ + public static function validateCellOrCellRange($cellRange): string + { + if (is_string($cellRange) || is_numeric($cellRange)) { + $cellRange = (string) $cellRange; + // Convert a single column reference like 'A' to 'A:A' + $cellRange = (string) preg_replace('/^([A-Z]+)$/', '${1}:${1}', $cellRange); + // Convert a single row reference like '1' to '1:1' + $cellRange = (string) preg_replace('/^(\d+)$/', '${1}:${1}', $cellRange); + } elseif (is_object($cellRange) && $cellRange instanceof CellAddress) { + $cellRange = new CellRange($cellRange, $cellRange); + } + + return self::validateCellRange($cellRange); + } + + /** + * Validate a cell range. + * + * @param AddressRange|array|string $cellRange Coordinate of the cells as a string, eg: 'C5:F12'; + * or as an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 12]), + * or as an AddressRange object. + */ + public static function validateCellRange($cellRange): string + { + if (is_string($cellRange)) { + [$worksheet, $addressRange] = Worksheet::extractSheetTitle($cellRange, true); + + // Convert Column ranges like 'A:C' to 'A1:C1048576' + $addressRange = (string) preg_replace('/^([A-Z]+):([A-Z]+)$/', '${1}1:${2}1048576', $addressRange); + // Convert Row ranges like '1:3' to 'A1:XFD3' + $addressRange = (string) preg_replace('/^(\\d+):(\\d+)$/', 'A${1}:XFD${2}', $addressRange); + + return empty($worksheet) ? strtoupper($addressRange) : $worksheet . '!' . strtoupper($addressRange); + } + + if (is_array($cellRange)) { + [$from, $to] = array_chunk($cellRange, 2); + $cellRange = new CellRange(CellAddress::fromColumnRowArray($from), CellAddress::fromColumnRowArray($to)); + } + + return (string) $cellRange; + } + + public static function definedNameToCoordinate(string $coordinate, Worksheet $worksheet): string + { + // Uppercase coordinate + $testCoordinate = strtoupper($coordinate); + // Eliminate leading equal sign + $testCoordinate = (string) preg_replace('/^=/', '', $coordinate); + $defined = $worksheet->getParent()->getDefinedName($testCoordinate, $worksheet); + if ($defined !== null) { + if ($defined->getWorksheet() === $worksheet && !$defined->isFormula()) { + $coordinate = (string) preg_replace('/^=/', '', $defined->getValue()); + } + } + + return $coordinate; + } +} diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index e4ce2ac3..70994be5 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -1067,7 +1067,7 @@ class Worksheet implements IComparable */ public function getHighestColumn($row = null) { - if (empty($row)) { + if ($row === null) { return Coordinate::stringFromColumnIndex($this->cachedHighestColumn); } @@ -1097,7 +1097,7 @@ class Worksheet implements IComparable */ public function getHighestRow($column = null) { - if ($column == null) { + if ($column === null) { return $this->cachedHighestRow; } @@ -1127,96 +1127,6 @@ class Worksheet implements IComparable return $this->cellCollection->getHighestRowAndColumn(); } - /** - * Validate a cell address. - * - * @param null|array|CellAddress|string $cellAddress Coordinate of the cell as a string, eg: 'C5'; - * or as an array of [$columnIndex, $row] (e.g. [3, 5]), or a CellAddress object. - */ - protected function validateCellAddress($cellAddress): string - { - if (is_string($cellAddress)) { - [$worksheet, $address] = self::extractSheetTitle($cellAddress, true); -// if (!empty($worksheet) && $worksheet !== $this->getTitle()) { -// throw new Exception('Reference is not for this worksheet'); -// } - - return empty($worksheet) ? strtoupper($address) : $worksheet . '!' . strtoupper($address); - } - - if (is_array($cellAddress)) { - $cellAddress = CellAddress::fromColumnRowArray($cellAddress); - } - - return (string) $cellAddress; - } - - private function tryDefinedName(string $coordinate): string - { - // Uppercase coordinate - $coordinate = strtoupper($coordinate); - // Eliminate leading equal sign - $coordinate = self::pregReplace('/^=/', '', $coordinate); - $defined = $this->parent->getDefinedName($coordinate, $this); - if ($defined !== null) { - if ($defined->getWorksheet() === $this && !$defined->isFormula()) { - $coordinate = self::pregReplace('/^=/', '', $defined->getValue()); - } - } - - return $coordinate; - } - - /** - * Validate a cell address or cell range. - * - * @param AddressRange|array|CellAddress|int|string $cellRange Coordinate of the cells as a string, eg: 'C5:F12'; - * or as an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 12]), - * or as a CellAddress or AddressRange object. - */ - protected function validateCellOrCellRange($cellRange): string - { - if (is_string($cellRange) || is_numeric($cellRange)) { - $cellRange = (string) $cellRange; - // Convert a single column reference like 'A' to 'A:A' - $cellRange = self::pregReplace('/^([A-Z]+)$/', '${1}:${1}', $cellRange); - // Convert a single row reference like '1' to '1:1' - $cellRange = self::pregReplace('/^(\d+)$/', '${1}:${1}', $cellRange); - } elseif (is_object($cellRange) && $cellRange instanceof CellAddress) { - $cellRange = new CellRange($cellRange, $cellRange); - } - - return $this->validateCellRange($cellRange); - } - - /** - * Validate a cell range. - * - * @param AddressRange|array|string $cellRange Coordinate of the cells as a string, eg: 'C5:F12'; - * or as an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 12]), - * or as an AddressRange object. - */ - protected function validateCellRange($cellRange): string - { - if (is_string($cellRange)) { - [$worksheet, $addressRange] = self::extractSheetTitle($cellRange, true); - - // Convert Column ranges like 'A:C' to 'A1:C1048576' - $addressRange = self::pregReplace('/^([A-Z]+):([A-Z]+)$/', '${1}1:${2}1048576', $addressRange); - // Convert Row ranges like '1:3' to 'A1:XFD3' - $addressRange = self::pregReplace('/^(\\d+):(\\d+)$/', 'A${1}:XFD${2}', $addressRange); - - return empty($worksheet) ? strtoupper($addressRange) : $worksheet . '!' . strtoupper($addressRange); - } - - if (is_array($cellRange)) { - [$from, $to] = array_chunk($cellRange, 2); - $cellRange = new CellRange(CellAddress::fromColumnRowArray($from), CellAddress::fromColumnRowArray($to)); - } - - return (string) $cellRange; - } - /** * Set a cell value. * @@ -1228,7 +1138,7 @@ class Worksheet implements IComparable */ public function setCellValue($coordinate, $value) { - $cellAddress = Functions::trimSheetFromCellReference($this->validateCellAddress($coordinate)); + $cellAddress = Functions::trimSheetFromCellReference(Validations::validateCellAddress($coordinate)); $this->getCell($cellAddress)->setValue($value); return $this; @@ -1266,7 +1176,7 @@ class Worksheet implements IComparable */ public function setCellValueExplicit($coordinate, $value, $dataType) { - $cellAddress = Functions::trimSheetFromCellReference($this->validateCellAddress($coordinate)); + $cellAddress = Functions::trimSheetFromCellReference(Validations::validateCellAddress($coordinate)); $this->getCell($cellAddress)->setValueExplicit($value, $dataType); return $this; @@ -1303,7 +1213,7 @@ class Worksheet implements IComparable */ public function getCell($coordinate): Cell { - $cellAddress = Functions::trimSheetFromCellReference($this->validateCellAddress($coordinate)); + $cellAddress = Functions::trimSheetFromCellReference(Validations::validateCellAddress($coordinate)); // Shortcut for increased performance for the vast majority of simple cases if ($this->cellCollection->has($cellAddress)) { @@ -1454,7 +1364,7 @@ class Worksheet implements IComparable */ public function cellExists($coordinate): bool { - $cellAddress = $this->validateCellAddress($coordinate); + $cellAddress = Validations::validateCellAddress($coordinate); /** @var Worksheet $sheet */ [$sheet, $finalCoordinate] = $this->getWorksheetAndCoordinate($cellAddress); @@ -1546,7 +1456,7 @@ class Worksheet implements IComparable */ public function getStyle($cellCoordinate): Style { - $cellCoordinate = $this->validateCellOrCellRange($cellCoordinate); + $cellCoordinate = Validations::validateCellOrCellRange($cellCoordinate); // set this sheet as active $this->parent->setActiveSheetIndex($this->parent->getIndex($this)); @@ -1784,7 +1694,7 @@ class Worksheet implements IComparable */ public function setBreak($coordinate, $break) { - $cellAddress = Functions::trimSheetFromCellReference($this->validateCellAddress($coordinate)); + $cellAddress = Functions::trimSheetFromCellReference(Validations::validateCellAddress($coordinate)); if ($break === self::BREAK_NONE) { if (isset($this->breaks[$cellAddress])) { @@ -1836,7 +1746,7 @@ class Worksheet implements IComparable */ public function mergeCells($range) { - $range = Functions::trimSheetFromCellReference($this->validateCellRange($range)); + $range = Functions::trimSheetFromCellReference(Validations::validateCellRange($range)); if (preg_match('/^([A-Z]+)(\\d+):([A-Z]+)(\\d+)$/', $range, $matches) === 1) { $this->mergeCells[$range] = $range; @@ -1945,7 +1855,7 @@ class Worksheet implements IComparable */ public function unmergeCells($range) { - $range = Functions::trimSheetFromCellReference($this->validateCellRange($range)); + $range = Functions::trimSheetFromCellReference(Validations::validateCellRange($range)); if (strpos($range, ':') !== false) { if (isset($this->mergeCells[$range])) { @@ -2023,7 +1933,7 @@ class Worksheet implements IComparable */ public function protectCells($range, $password, $alreadyHashed = false) { - $range = Functions::trimSheetFromCellReference($this->validateCellOrCellRange($range)); + $range = Functions::trimSheetFromCellReference(Validations::validateCellOrCellRange($range)); if (!$alreadyHashed) { $password = Shared\PasswordHasher::hashPassword($password); @@ -2071,7 +1981,7 @@ class Worksheet implements IComparable */ public function unprotectCells($range) { - $range = Functions::trimSheetFromCellReference($this->validateCellOrCellRange($range)); + $range = Functions::trimSheetFromCellReference(Validations::validateCellOrCellRange($range)); if (isset($this->protectedCells[$range])) { unset($this->protectedCells[$range]); @@ -2142,7 +2052,7 @@ class Worksheet implements IComparable if (is_object($autoFilterOrRange) && ($autoFilterOrRange instanceof AutoFilter)) { $this->autoFilter = $autoFilterOrRange; } else { - $cellRange = Functions::trimSheetFromCellReference($this->validateCellRange($autoFilterOrRange)); + $cellRange = Functions::trimSheetFromCellReference(Validations::validateCellRange($autoFilterOrRange)); $this->autoFilter->setRange($cellRange); } @@ -2216,13 +2126,13 @@ class Worksheet implements IComparable public function freezePane($coordinate, $topLeftCell = null) { $cellAddress = ($coordinate !== null) - ? Functions::trimSheetFromCellReference($this->validateCellAddress($coordinate)) + ? Functions::trimSheetFromCellReference(Validations::validateCellAddress($coordinate)) : null; if ($cellAddress !== null && Coordinate::coordinateIsRange($cellAddress)) { throw new Exception('Freeze pane can not be set on a range of cells.'); } $topLeftCell = ($topLeftCell !== null) - ? Functions::trimSheetFromCellReference($this->validateCellAddress($topLeftCell)) + ? Functions::trimSheetFromCellReference(Validations::validateCellAddress($topLeftCell)) : null; if ($cellAddress !== null && $topLeftCell === null) { @@ -2626,7 +2536,7 @@ class Worksheet implements IComparable */ public function getComment($cellCoordinate) { - $cellAddress = Functions::trimSheetFromCellReference($this->validateCellAddress($cellCoordinate)); + $cellAddress = Functions::trimSheetFromCellReference(Validations::validateCellAddress($cellCoordinate)); if (Coordinate::coordinateIsRange($cellAddress)) { throw new Exception('Cell coordinate string can not be a range of cells.'); @@ -2697,22 +2607,6 @@ class Worksheet implements IComparable return $this->setSelectedCells($coordinate); } - /** - * Sigh - Phpstan thinks, correctly, that preg_replace can return null. - * But Scrutinizer doesn't. Try to satisfy both. - * - * @param mixed $str - */ - private static function ensureString($str): string - { - return is_string($str) ? $str : ''; - } - - public static function pregReplace(string $pattern, string $replacement, string $subject): string - { - return self::ensureString(preg_replace($pattern, $replacement, $subject)); - } - /** * Select a range of cells. * @@ -2725,9 +2619,9 @@ class Worksheet implements IComparable public function setSelectedCells($coordinate) { if (is_string($coordinate)) { - $coordinate = $this->tryDefinedName($coordinate); + $coordinate = Validations::definedNameToCoordinate($coordinate, $this); } - $coordinate = $this->validateCellOrCellRange($coordinate); + $coordinate = Validations::validateCellOrCellRange($coordinate); if (Coordinate::coordinateIsRange($coordinate)) { [$first] = Coordinate::splitRange($coordinate); From 76310a05fdbbe1de2ab5cb7799dda19e4fcd6c37 Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Sat, 23 Apr 2022 13:35:09 +0200 Subject: [PATCH 46/52] Update PR Template --- .github/PULL_REQUEST_TEMPLATE.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index eba1f1e2..5d9b33ec 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -3,14 +3,21 @@ This is: ``` - [ ] a bugfix - [ ] a new feature +- [ ] refactoring +- [ ] additional unit tests ``` Checklist: - [ ] Changes are covered by unit tests + - [ ] Changes are covered by existing unit tests + - [ ] New unit tests have been added - [ ] Code style is respected - [ ] Commit message explains **why** the change is made (see https://github.com/erlang/otp/wiki/Writing-good-commit-messages) - [ ] CHANGELOG.md contains a short summary of the change - [ ] Documentation is updated as necessary ### Why this change is needed? + +Provide an explanation of why this change is needed, with links to any Issues (if appropriate). +If this is a bugfix or a new feature, and there are no existing Issues, then please also create an issue that will mae it easier to track progress with this PR. From 0facbe0c1b04a060fa2ec7601f917b6ba33bcba4 Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Sat, 23 Apr 2022 13:35:56 +0200 Subject: [PATCH 47/52] Fix typo in PR template --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5d9b33ec..f42df68c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -20,4 +20,4 @@ Checklist: ### Why this change is needed? Provide an explanation of why this change is needed, with links to any Issues (if appropriate). -If this is a bugfix or a new feature, and there are no existing Issues, then please also create an issue that will mae it easier to track progress with this PR. +If this is a bugfix or a new feature, and there are no existing Issues, then please also create an issue that will make it easier to track progress with this PR. From ed11a41c1933c2d1a037dff132a660676b6975c1 Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Sat, 23 Apr 2022 13:46:42 +0200 Subject: [PATCH 48/52] Updates to the Issue template --- .github/ISSUE_TEMPLATE.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 05e3b199..c3a74ede 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -26,6 +26,20 @@ $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); // add code that show the issue here... ``` +If this is an issue with reading a specific spreadsheet file, then it may be appropriate to provide a sample file that demonstrates the problem; but please keep it as small as possible, and sanitize any confidential information before uploading. + +### What features do you think are causing the issue + +- [ ] Reader +- [ ] Writer +- [ ] Styles +- [ ] Data Validations +- [ ] Formula Calulations +- [ ] Charts +- [ ] AutoFilter +- [ ] Form Elements + +### Does an issue affect all spreadsheet file formats? If not, which formats are affected? ### Which versions of PhpSpreadsheet and PHP are affected? From d414f139f144bd3f387c7c5f223a8d024913c6ff Mon Sep 17 00:00:00 2001 From: aswinkumar863 Date: Sat, 23 Apr 2022 18:42:11 +0530 Subject: [PATCH 49/52] Table name is now case insensitive Table name comparison changed to UTF-8 aware and case insensitive --- src/PhpSpreadsheet/Worksheet/Table.php | 4 +++- src/PhpSpreadsheet/Worksheet/Worksheet.php | 3 ++- tests/PhpSpreadsheetTests/Worksheet/Table/RemoveTableTest.php | 2 +- tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/PhpSpreadsheet/Worksheet/Table.php b/src/PhpSpreadsheet/Worksheet/Table.php index 35966d6f..4ec4cbfc 100644 --- a/src/PhpSpreadsheet/Worksheet/Table.php +++ b/src/PhpSpreadsheet/Worksheet/Table.php @@ -4,6 +4,7 @@ namespace PhpOffice\PhpSpreadsheet\Worksheet; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Exception as PhpSpreadsheetException; +use PhpOffice\PhpSpreadsheet\Shared\StringHelper; use PhpOffice\PhpSpreadsheet\Worksheet\Table\TableStyle; class Table @@ -226,10 +227,11 @@ class Table { if ($this->name !== '' && $worksheet !== null) { $spreadsheet = $worksheet->getParent(); + $tableName = StringHelper::strToUpper($this->name); foreach ($spreadsheet->getWorksheetIterator() as $sheet) { foreach ($sheet->getTableCollection() as $table) { - if ($table->getName() === $this->name) { + if (StringHelper::strToUpper($table->getName()) === $tableName) { throw new PhpSpreadsheetException("Workbook already contains a table named '{$this->name}'"); } } diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index 80f51b44..66315083 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -2158,8 +2158,9 @@ class Worksheet implements IComparable */ public function removeTableByName(string $name): self { + $name = Shared\StringHelper::strToUpper($name); foreach ($this->tableCollection as $key => $table) { - if ($table->getName() === $name) { + if (Shared\StringHelper::strToUpper($table->getName()) === $name) { unset($this->tableCollection[$key]); } } diff --git a/tests/PhpSpreadsheetTests/Worksheet/Table/RemoveTableTest.php b/tests/PhpSpreadsheetTests/Worksheet/Table/RemoveTableTest.php index 0495b11c..fb2bb939 100644 --- a/tests/PhpSpreadsheetTests/Worksheet/Table/RemoveTableTest.php +++ b/tests/PhpSpreadsheetTests/Worksheet/Table/RemoveTableTest.php @@ -18,7 +18,7 @@ class RemoveTableTest extends SetupTeardown self::assertEquals(1, $sheet->getTableCollection()->count()); - $sheet->removeTableByName('Table1'); + $sheet->removeTableByName('table1'); // case insensitive self::assertEquals(0, $sheet->getTableCollection()->count()); } diff --git a/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php b/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php index af205196..dd49aae1 100644 --- a/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php +++ b/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php @@ -87,7 +87,7 @@ class TableTest extends SetupTeardown $sheet->addTable($table1); $table2 = new Table(); - $table2->setName('Table_1'); + $table2->setName('table_1'); // case insensitive $sheet->addTable($table2); } From 534cbc04c040079c26c8855670ba8ed94566c176 Mon Sep 17 00:00:00 2001 From: aswinkumar863 Date: Sat, 23 Apr 2022 18:43:38 +0530 Subject: [PATCH 50/52] Accept table range as AddressRange and array Table constructor now accepts AddressRange and array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] --- src/PhpSpreadsheet/Worksheet/Table.php | 21 +++++++--- src/PhpSpreadsheet/Worksheet/Worksheet.php | 21 ---------- .../Worksheet/Table/TableTest.php | 39 ++++++++++++------- 3 files changed, 39 insertions(+), 42 deletions(-) diff --git a/src/PhpSpreadsheet/Worksheet/Table.php b/src/PhpSpreadsheet/Worksheet/Table.php index 4ec4cbfc..66839d41 100644 --- a/src/PhpSpreadsheet/Worksheet/Table.php +++ b/src/PhpSpreadsheet/Worksheet/Table.php @@ -2,6 +2,7 @@ namespace PhpOffice\PhpSpreadsheet\Worksheet; +use PhpOffice\PhpSpreadsheet\Cell\AddressRange; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Exception as PhpSpreadsheetException; use PhpOffice\PhpSpreadsheet\Shared\StringHelper; @@ -61,12 +62,13 @@ class Table /** * Create a new Table. * - * @param string $range (e.g. A1:D4) + * @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. * @param string $name (e.g. Table1) - * - * @return $this */ - public function __construct(string $range = '', string $name = '') + public function __construct($range = '', string $name = '') { $this->setRange($range); $this->setName($name); @@ -161,11 +163,18 @@ class Table /** * 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(string $range): self + public function setRange($range = ''): self { // extract coordinate - [$worksheet, $range] = Worksheet::extractSheetTitle($range, true); + if ($range !== '') { + [, $range] = Worksheet::extractSheetTitle(Validations::validateCellRange($range), true); + } if (empty($range)) { // Discard all column rules $this->columns = []; diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index 66315083..0baf6ecd 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -2128,27 +2128,6 @@ class Worksheet implements IComparable 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; - - $table = new Table($cellRange); - $table->setWorksheet($this); - $this->addTable($table); - - return $this; - } - /** * Remove Table by name. * diff --git a/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php b/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php index dd49aae1..c7cbee25 100644 --- a/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php +++ b/tests/PhpSpreadsheetTests/Worksheet/Table/TableTest.php @@ -2,6 +2,8 @@ namespace PhpOffice\PhpSpreadsheetTests\Worksheet\Table; +use PhpOffice\PhpSpreadsheet\Cell\CellAddress; +use PhpOffice\PhpSpreadsheet\Cell\CellRange; use PhpOffice\PhpSpreadsheet\Exception as PhpSpreadsheetException; use PhpOffice\PhpSpreadsheet\Worksheet\Table; use PhpOffice\PhpSpreadsheet\Worksheet\Table\Column; @@ -133,25 +135,32 @@ class TableTest extends SetupTeardown self::assertEquals($expectedResult, $result); } - public function testSetRange(): void + /** + * @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(); - $table = new Table(self::INITIAL_RANGE); - $ranges = [ - 'G1:J512' => "$title!G1:J512", - 'K1:N20' => 'K1:N20', + + 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'], ]; - - 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 From b6f9868042d68cd2c88337b0e507608357acc315 Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Sat, 23 Apr 2022 19:01:10 +0200 Subject: [PATCH 51/52] Quickfix - default Worksheet in CellAddress constructor to null --- src/PhpSpreadsheet/Cell/CellAddress.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PhpSpreadsheet/Cell/CellAddress.php b/src/PhpSpreadsheet/Cell/CellAddress.php index 192fcfe3..d587dcd1 100644 --- a/src/PhpSpreadsheet/Cell/CellAddress.php +++ b/src/PhpSpreadsheet/Cell/CellAddress.php @@ -32,7 +32,7 @@ class CellAddress */ protected $rowId; - public function __construct(string $cellAddress, ?Worksheet $worksheet) + public function __construct(string $cellAddress, ?Worksheet $worksheet = null) { $this->cellAddress = str_replace('$', '', $cellAddress); [$this->columnName, $rowId] = Coordinate::coordinateFromString($cellAddress); From 27221ee8cfadde1467e41c67f3fb7ab81a39e6e9 Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Sat, 23 Apr 2022 19:44:41 +0200 Subject: [PATCH 52/52] Update Change Log --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f63dfa3..5899ef3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,11 @@ and this project adheres to [Semantic Versioning](https://semver.org). Ranges and Rules are read, but style is currently limited to font size, weight and color; and to fill style and color. - Add ability to suppress Mac line ending check for CSV [#2623](https://github.com/PHPOffice/PhpSpreadsheet/pull/2623) +- Initial support for creating and writing Tables (Xlsx Writer only) [PR #2671](https://github.com/PHPOffice/PhpSpreadsheet/pull/2671) + + See `/samples/Table` for examples of use. + + Note that PreCalculateFormulas needs to be disabled when saving spreadsheets containing tables with formulae (totals or column formulae). ### Changed