From 6a0d4ff52765ec81b4a50e660a9436c5851f5003 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Tue, 9 Jun 2026 12:26:11 -0700 Subject: [PATCH 01/24] No lumOff for Radar Charts Fix #661 (marked stale in 2018, but now reopened). That issue was already mostly resolved by many changes to Xlsx Chart Writer logic some time ago. However, a new problem popped up. PR #2950 added `brightness` logic to Xlsx Reader and Writer. That was done primarily for the benefit of scatter charts. Xlsx Writer writes two brightness properties `lumMod` and `lumOff`. These values are complete complementary (if you know one, you know the other), so I am not sure why both are needed, but my scatter chart testing indicated that they were. It turns out that Radar charts can also set brightness, but, if the writer specifies both `lumMod` and `lumOff`, the resulting chart is slightly off. There may be more to this, but that's all I can deal with for now - I suppress writing `lumOff` if we're writing a radar chart. If there are other problems in this area, I will wait for them to be reported. None of the existing radar chart samples used the brightness properties; however, the example attached to 661 did. It is added as a new Sample, and tests are added. --- samples/templates/32readwriteRadarChart4.xlsx | Bin 0 -> 35187 bytes src/PhpSpreadsheet/Writer/Xlsx/Chart.php | 11 ++-- .../Chart/Charts32Radar4Test.php | 48 ++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 samples/templates/32readwriteRadarChart4.xlsx create mode 100644 tests/PhpSpreadsheetTests/Chart/Charts32Radar4Test.php diff --git a/samples/templates/32readwriteRadarChart4.xlsx b/samples/templates/32readwriteRadarChart4.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..d752fb8b641fee8477fcb89a7c9507c9dcbc66c7 GIT binary patch literal 35187 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 literal 0 HcmV?d00001 diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Chart.php b/src/PhpSpreadsheet/Writer/Xlsx/Chart.php index db8c0321c..3cf39dbbd 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Chart.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Chart.php @@ -304,6 +304,8 @@ class Chart extends WriterPart $objWriter->endElement(); // c:legend } + private string $chartType = ''; + /** * Write Chart Plot Area. */ @@ -327,6 +329,7 @@ class Chart extends WriterPart $chartType = null; foreach ($chartTypes as $chartType) { $objWriter->startElement('c:' . $chartType); + $this->chartType = $chartType; $groupCount = $plotArea->getPlotGroupCount(); $plotGroup = null; @@ -1875,9 +1878,11 @@ class Chart extends WriterPart $objWriter->startElement('a:lumMod'); $objWriter->writeAttribute('val', ChartColor::alphaToXml($brightness)); $objWriter->endElement(); // a:lumMod - $objWriter->startElement('a:lumOff'); - $objWriter->writeAttribute('val', ChartColor::alphaToXml($lumOff)); - $objWriter->endElement(); // a:lumOff + if ($this->chartType !== DataSeries::TYPE_RADARCHART) { + $objWriter->startElement('a:lumOff'); + $objWriter->writeAttribute('val', ChartColor::alphaToXml($lumOff)); + $objWriter->endElement(); // a:lumOff + } } $objWriter->endElement(); //a:srgbClr/schemeClr/prstClr if ($solidFill) { diff --git a/tests/PhpSpreadsheetTests/Chart/Charts32Radar4Test.php b/tests/PhpSpreadsheetTests/Chart/Charts32Radar4Test.php new file mode 100644 index 000000000..81af8e6f7 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Chart/Charts32Radar4Test.php @@ -0,0 +1,48 @@ +outputFileName !== '') { + unlink($this->outputFileName); + $this->outputFileName = ''; + } + } + + public function test1LummodNoLumoff(): void + { + $infile = 'samples/templates/32readwriteRadarChart4.xlsx'; + $reader = new XlsxReader(); + $reader->setIncludeCharts(true); + $spreadsheet = $reader->load($infile); + + $writer = new XlsxWriter($spreadsheet); + $writer->setIncludeCharts(true); + $this->outputFileName = File::temporaryFilename(); + $writer->save($this->outputFileName); + $spreadsheet->disconnectWorksheets(); + + $file = 'zip://'; + $file .= $this->outputFileName; + $file .= '#xl/charts/chart2.xml'; + $data = file_get_contents($file); + self::assertNotFalse($data); + self::assertSame(2, substr_count($data, ' Date: Tue, 9 Jun 2026 17:02:05 -0700 Subject: [PATCH 02/24] Navigate Cell as Cursor Fix #863, which went stale in 2019 and is now reopened. Add methods to `Cell` which operate in a cursor-like manner, allowing you to change to cells on the right, left, down, or up. --- src/PhpSpreadsheet/Cell/Cell.php | 48 +++++++++++++++++ tests/PhpSpreadsheetTests/Cell/CursorTest.php | 54 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 tests/PhpSpreadsheetTests/Cell/CursorTest.php diff --git a/src/PhpSpreadsheet/Cell/Cell.php b/src/PhpSpreadsheet/Cell/Cell.php index 2b0da2fa5..10fb875ed 100644 --- a/src/PhpSpreadsheet/Cell/Cell.php +++ b/src/PhpSpreadsheet/Cell/Cell.php @@ -1071,4 +1071,52 @@ class Cell implements Stringable return $hidden !== Protection::PROTECTION_UNPROTECTED; } + + /** + * Return cell $right positions to the right of this one. + */ + public function cursorRight(int $right = 1): self + { + $row = $this->getRow(); + $col = $this->getColumn(); + $colIndex = Coordinate::columnIndexFromString($col); + $newCol = max( + 1, + min($colIndex + $right, AddressRange::MAX_COLUMN_INT) + ); + $newColStr = Coordinate::stringFromColumnIndex($newCol); + + return $this->getWorksheet()->getCell("$newColStr$row"); + } + + /** + * Return cell $right positions to the left of this one. + */ + public function cursorLeft(int $left = 1): self + { + return $this->cursorRight(-$left); + } + + /** + * Return cell $down positions below this one. + */ + public function cursorDown(int $down = 1): self + { + $row = $this->getRow(); + $col = $this->getColumn(); + $newRow = max( + 1, + min($row + $down, AddressRange::MAX_ROW) + ); + + return $this->getWorksheet()->getCell("$col$newRow"); + } + + /** + * Return cell $up positions above this one. + */ + public function cursorUp(int $up = 1): self + { + return $this->cursorDown(-$up); + } } diff --git a/tests/PhpSpreadsheetTests/Cell/CursorTest.php b/tests/PhpSpreadsheetTests/Cell/CursorTest.php new file mode 100644 index 000000000..6091bd3cc --- /dev/null +++ b/tests/PhpSpreadsheetTests/Cell/CursorTest.php @@ -0,0 +1,54 @@ +getActiveSheet(); + $sheet->fromArray($data); + $cell = $sheet->getCell('A1')->cursorRight(); + self::assertSame('1b', $cell->getValue()); + $cell = $cell->cursorDown(2)->cursorLeft(); + self::assertSame('3a', $cell->getValue()); + $cell = $cell->cursorUp()->cursorRight(3); + self::assertSame('2d', $cell->getValue()); + $cell = $cell->cursorUp(2); + self::assertSame('1d', $cell->getValue(), 'no move above row 1'); + $cell = $cell->cursorLeft(5); + self::assertSame('1a', $cell->getValue(), 'no move to the left of column A'); + $cell = $cell->cursorRight(AddressRange::MAX_COLUMN_INT); + self::assertSame(AddressRange::MAX_COLUMN . '1', $cell->getCoordinate(), 'no column beyond MAX_COLUMN_INT'); + $cell = $cell->cursorDown(AddressRange::MAX_ROW); + self::assertSame(AddressRange::MAX_COLUMN . AddressRange::MAX_ROW, $cell->getCoordinate(), 'no row beyond MAX_ROW'); + self::assertSame( + '4d', + $sheet->getCell('A2') + ->cursorDown(2) // takes us to A4 + ->cursorRight(3) // takes us to D4 + ->getValue() + ); + $sheet->getCell('H5')->setValue(15) + ->cursorDown()->setValue(16) + ->cursorDown()->setValue(17); + self::assertSame(15, $sheet->getCell('H5')->getValue()); + self::assertSame(16, $sheet->getCell('H6')->getValue()); + self::assertSame(17, $sheet->getCell('H7')->getValue()); + $spreadsheet->disconnectWorksheets(); + } +} From 46f4b8995a5e8e17f900019168dfb198986ef1de Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:30:10 -0700 Subject: [PATCH 03/24] Additional Methods and Tests --- src/PhpSpreadsheet/Cell/Cell.php | 46 ++++++++++++++++++- tests/PhpSpreadsheetTests/Cell/CursorTest.php | 13 ++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/PhpSpreadsheet/Cell/Cell.php b/src/PhpSpreadsheet/Cell/Cell.php index 10fb875ed..ca13e824f 100644 --- a/src/PhpSpreadsheet/Cell/Cell.php +++ b/src/PhpSpreadsheet/Cell/Cell.php @@ -1090,7 +1090,7 @@ class Cell implements Stringable } /** - * Return cell $right positions to the left of this one. + * Return cell $left positions to the left of this one. */ public function cursorLeft(int $left = 1): self { @@ -1119,4 +1119,48 @@ class Cell implements Stringable { return $this->cursorDown(-$up); } + + /** + * Return cell at row $row in current column. + */ + public function cursorRow(int $row = 1): self + { + $col = $this->getColumn(); + $newRow = max( + 1, + min($row, AddressRange::MAX_ROW) + ); + + return $this->getWorksheet()->getCell("$col$newRow"); + } + + /** + * Return cell at column $column in current row. + */ + public function cursorColumn(string $column = 'A'): self + { + $row = $this->getRow(); + $colIndex = Coordinate::columnIndexFromString($column); + $newCol = max( + 1, + min($colIndex, AddressRange::MAX_COLUMN_INT) + ); + $newColStr = Coordinate::stringFromColumnIndex($newCol); + + return $this->getWorksheet()->getCell("$newColStr$row"); + } + + /** + * Return cell adjusted for Xls limits if applicable. + */ + public function cursorXlsLimits(): self + { + $row = min($this->getRow(), AddressRange::MAX_ROW_XLS); + $column = $this->getColumn(); + $colIndex = Coordinate::columnIndexFromString($column); + $newCol = min($colIndex, AddressRange::MAX_COLUMN_INT_XLS); + $newColStr = Coordinate::stringFromColumnIndex($newCol); + + return $this->getWorksheet()->getCell("$newColStr$row"); + } } diff --git a/tests/PhpSpreadsheetTests/Cell/CursorTest.php b/tests/PhpSpreadsheetTests/Cell/CursorTest.php index 6091bd3cc..61ca3c61a 100644 --- a/tests/PhpSpreadsheetTests/Cell/CursorTest.php +++ b/tests/PhpSpreadsheetTests/Cell/CursorTest.php @@ -49,6 +49,19 @@ class CursorTest extends TestCase self::assertSame(15, $sheet->getCell('H5')->getValue()); self::assertSame(16, $sheet->getCell('H6')->getValue()); self::assertSame(17, $sheet->getCell('H7')->getValue()); + + $cell = $sheet->getCell('H5')->cursorXlsLimits(); + self::assertSame('H5', $cell->getCoordinate()); + $cell = $cell->cursorRow(2); + self::assertSame('H2', $cell->getCoordinate()); + $cell = $cell->cursorRow(0); + self::assertSame('H1', $cell->getCoordinate()); + $cell = $cell->cursorColumn('ABC'); + self::assertSame('ABC1', $cell->getCoordinate()); + + $cell = $sheet->getCell('JK71234')->cursorXlsLimits(); + self::assertSame('IV65536', $cell->getCoordinate()); + $spreadsheet->disconnectWorksheets(); } } From a028eea3141f4a7ca4dc998613417f1c18b2172a Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 11 Jun 2026 06:30:20 -0700 Subject: [PATCH 04/24] Allow Use of Multiple Ranges When Setting Style Fix #411, which went stale in 2018 and is now reopened. You can get a style for a set of cells which uses multiple ranges, e.g. `A1:B2,C5:H10`, but we throw an exception if you try to change the style. I don't know how useful that is, but it's been requested and it's pretty easy to implement with recursion. The comma in the ranges represents the `union` operator. `getStyle` is very permissive - it will allow almost anything to be specified. However, attempts to use a space as the `intersection` operator, or, indeed, many other nonsense strings, will still be permitted for `getStyle` but will continue to throw an Exception if an attempt is made to alter the style. --- src/PhpSpreadsheet/Style/Style.php | 12 +++ .../Style/MultipleRangeTest.php | 84 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 tests/PhpSpreadsheetTests/Style/MultipleRangeTest.php diff --git a/src/PhpSpreadsheet/Style/Style.php b/src/PhpSpreadsheet/Style/Style.php index 9956c358d..9df62897a 100644 --- a/src/PhpSpreadsheet/Style/Style.php +++ b/src/PhpSpreadsheet/Style/Style.php @@ -201,6 +201,18 @@ class Style extends Supervisor { if ($this->isSupervisor) { $pRange = $this->getSelectedCells(); + if (str_contains($pRange, ',')) { + $ranges = explode(',', $pRange); + $sheet = $this->getActiveSheet(); + foreach ($ranges as $range) { + $sheet + ->setSelectedCells(trim($range)); + $this->applyFromArray($styleArray, $advancedBorders); + } + $sheet->setSelectedCells($pRange); + + return $this; + } // Uppercase coordinate and strip any Worksheet reference from the selected range $pRange = strtoupper($pRange); diff --git a/tests/PhpSpreadsheetTests/Style/MultipleRangeTest.php b/tests/PhpSpreadsheetTests/Style/MultipleRangeTest.php new file mode 100644 index 000000000..c07161beb --- /dev/null +++ b/tests/PhpSpreadsheetTests/Style/MultipleRangeTest.php @@ -0,0 +1,84 @@ +getActiveSheet(); + $data = [ + [10, 11, 12, 13, 14, 15], + [20, 21, 22, 23, 24, 25], + [30, 31, 32, 33, 34, 35], + [40, 41, 42, 43, 44, 45], + [50, 51, 52, 53, 54, 55], + [60, 61, 62, 63, 64, 65], + ]; + $sheet->fromArray($data); + $styleArray = ['font' => ['bold' => true]]; + $range1 = 'A2:C5,E2:F5'; + $range1Array = Coordinate::extractAllCellReferencesInRange($range1); + $sheet + ->getStyle($range1) + ->applyFromArray($styleArray); + self::assertSame($range1, $sheet->getSelectedCells()); + self::assertTrue( + $sheet->getStyle($range1)->getFont()->getBold() + ); + $range2 = 'A1,F6,B2:D3'; + $range2Array = Coordinate::extractAllCellReferencesInRange($range2); + $sheet + ->getStyle($range2) + ->getFont() + ->setItalic(true); + self::assertSame($range2, $sheet->getSelectedCells()); + self::assertTrue( + $sheet->getStyle($range2)->getFont()->getItalic() + ); + // A1 is part of range2 but not range1 + self::assertNotContains('A1', $range1Array); + self::assertContains('A1', $range2Array); + self::assertTrue( + $sheet->getStyle('A1')->getFont()->getItalic() + ); + self::assertFalse( + $sheet->getStyle('A1')->getFont()->getBold() + ); + // B1 is part of neither range2 nor range1 + self::assertNotContains('B1', $range1Array); + self::assertNotContains('B1', $range2Array); + self::assertFalse( + $sheet->getStyle('B1')->getFont()->getItalic() + ); + self::assertFalse( + $sheet->getStyle('B1')->getFont()->getBold() + ); + // B2 is part of both range2 and range1 + self::assertContains('B2', $range1Array); + self::assertContains('B2', $range2Array); + self::assertTrue( + $sheet->getStyle('B2')->getFont()->getItalic() + ); + self::assertTrue( + $sheet->getStyle('B2')->getFont()->getBold() + ); + // C4 is part of range1 but not range2 + self::assertContains('C4', $range1Array); + self::assertNotContains('C4', $range2Array); + self::assertTrue( + $sheet->getStyle('C4')->getFont()->getBold() + ); + self::assertFalse( + $sheet->getStyle('C4')->getFont()->getItalic() + ); + $spreadsheet->disconnectWorksheets(); + } +} From dbea64cb03300abdc236a0b9f4a779ec1a80c2f6 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 11 Jun 2026 06:43:42 -0700 Subject: [PATCH 05/24] Chart DataTable Fix #413, which went stale in 2018 and is now reopened. A DataTable can be used on a chart in place of a Legend (or in addition to one, but it doesn't really make sense to have both). The fix was substantially developed by @topaDev before the issue went stale. This PR provides basic support for reading and writing DataTables on charts. The new sample on this PR shows them in action. They have obscure features which are not implemented (e.g. `glow`); those will have to wait for another day. --- samples/Chart33a/33_Chart_create_area_3.php | 108 +++++++++++ src/PhpSpreadsheet/Chart/DataTable.php | 62 +++++++ src/PhpSpreadsheet/Chart/PlotArea.php | 14 ++ src/PhpSpreadsheet/Reader/Xlsx/Chart.php | 35 +++- src/PhpSpreadsheet/Writer/Xlsx/Chart.php | 28 +++ .../Chart/DataTableTest.php | 173 ++++++++++++++++++ 6 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 samples/Chart33a/33_Chart_create_area_3.php create mode 100644 src/PhpSpreadsheet/Chart/DataTable.php create mode 100644 tests/PhpSpreadsheetTests/Chart/DataTableTest.php diff --git a/samples/Chart33a/33_Chart_create_area_3.php b/samples/Chart33a/33_Chart_create_area_3.php new file mode 100644 index 000000000..5e5a3f802 --- /dev/null +++ b/samples/Chart33a/33_Chart_create_area_3.php @@ -0,0 +1,108 @@ +getTheme()->setThemeColorName(SpreadsheetTheme::COLOR_SCHEME_2013_2022_NAME); +$worksheet = $spreadsheet->getActiveSheet(); +$sheet2 = $spreadsheet->createSheet(); +$sheet2->setTitle('Sheet2'); +$worksheet->fromArray( + [ + ['', 2010, 2011, 2012], + ['Q1', 12, 15, 21], + ['Q2', 56, 73, 86], + ['Q3', 52, 61, 69], + ['Q4', 30, 32, 0], + ] +); + +// Set the Labels for each data series we want to plot +// Datatype +// Cell reference for data +// Format Code +// Number of datapoints in series +// Data values +// Data Marker +$dataSeriesLabels = [ + new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_STRING, 'Worksheet!$B$1', null, 1), // 2010 + new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_STRING, 'Worksheet!$C$1', null, 1), // 2011 + new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_STRING, 'Worksheet!$D$1', null, 1), // 2012 +]; +// Set the X-Axis Labels +// Datatype +// Cell reference for data +// Format Code +// Number of datapoints in series +// Data values +// Data Marker +$xAxisTickValues = [ + new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_STRING, 'Worksheet!$A$2:$A$5', null, 4), // Q1 to Q4 +]; +// Set the Data values for each data series we want to plot +// Datatype +// Cell reference for data +// Format Code +// Number of datapoints in series +// Data values +// Data Marker +$dataSeriesValues = [ + new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_NUMBER, 'Worksheet!$B$2:$B$5', null, 4), + new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_NUMBER, 'Worksheet!$C$2:$C$5', null, 4), + new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_NUMBER, 'Worksheet!$D$2:$D$5', null, 4), +]; + +// Build the dataseries +$series = new DataSeries( + DataSeries::TYPE_AREACHART, // plotType + DataSeries::GROUPING_PERCENT_STACKED, // plotGrouping + range(0, count($dataSeriesValues) - 1), // plotOrder + $dataSeriesLabels, // plotLabel + $xAxisTickValues, // plotCategory + $dataSeriesValues // plotValues +); + +// Set the series in the plot area +$plotArea = new PlotArea(null, [$series]); +$plotArea->setDataTable(new DataTable()); +// No need for Legend if using DataTable +$legend = null; //new ChartLegend(ChartLegend::POSITION_TOPRIGHT, null, false); + +$title = new Title('Test %age-Stacked Area Chart'); +$yAxisLabel = new Title('Value ($k)'); + +// Create the chart +$chart = new Chart( + 'chart1', // name + $title, // title + $legend, // legend + $plotArea, // plotArea + true, // plotVisibleOnly + DataSeries::EMPTY_AS_GAP, // displayBlanksAs + null, // xAxisLabel + $yAxisLabel // yAxisLabel +); + +// Set the position where the chart should appear in the worksheet +$chart->setTopLeftPosition('A1'); +$chart->setBottomRightPosition('H18'); + +// Add the chart to the worksheet +$sheet2->addChart($chart); +$spreadsheet->setActiveSheetIndex(1); + +$helper->renderChart($chart, __FILE__); + +// Save Excel 2007 file +$helper->write($spreadsheet, __FILE__, ['Xlsx'], true, null, false); diff --git a/src/PhpSpreadsheet/Chart/DataTable.php b/src/PhpSpreadsheet/Chart/DataTable.php new file mode 100644 index 000000000..71c18fe7c --- /dev/null +++ b/src/PhpSpreadsheet/Chart/DataTable.php @@ -0,0 +1,62 @@ +showHorizontalBorder; + } + + public function getShowVerticalBorder(): bool + { + return $this->showVerticalBorder; + } + + public function getShowOutline(): bool + { + return $this->showOutline; + } + + public function getShowKeys(): bool + { + return $this->showKeys; + } + + public function setShowHorizontalBorder(bool $showHorizontalBorder): self + { + $this->showHorizontalBorder = $showHorizontalBorder; + + return $this; + } + + public function setShowVerticalBorder(bool $showVerticalBorder): self + { + $this->showVerticalBorder = $showVerticalBorder; + + return $this; + } + + public function setShowOutline(bool $showOutline): self + { + $this->showOutline = $showOutline; + + return $this; + } + + public function setShowKeys(bool $showKeys): self + { + $this->showKeys = $showKeys; + + return $this; + } +} diff --git a/src/PhpSpreadsheet/Chart/PlotArea.php b/src/PhpSpreadsheet/Chart/PlotArea.php index e5e390789..05a9ce20e 100644 --- a/src/PhpSpreadsheet/Chart/PlotArea.php +++ b/src/PhpSpreadsheet/Chart/PlotArea.php @@ -38,6 +38,8 @@ class PlotArea */ private array $plotSeries; + private ?DataTable $dataTable = null; + /** * Create a new PlotArea. * @@ -195,6 +197,18 @@ class PlotArea return $this; } + public function setDataTable(DataTable $dataTable): self + { + $this->dataTable = $dataTable; + + return $this; + } + + public function getDataTable(): ?DataTable + { + return $this->dataTable; + } + /** * Implement PHP __clone to create a deep clone, not just a shallow copy. */ diff --git a/src/PhpSpreadsheet/Reader/Xlsx/Chart.php b/src/PhpSpreadsheet/Reader/Xlsx/Chart.php index 4ae3fd4e2..2b6dfb687 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx/Chart.php +++ b/src/PhpSpreadsheet/Reader/Xlsx/Chart.php @@ -8,6 +8,7 @@ use PhpOffice\PhpSpreadsheet\Chart\AxisText; use PhpOffice\PhpSpreadsheet\Chart\ChartColor; use PhpOffice\PhpSpreadsheet\Chart\DataSeries; use PhpOffice\PhpSpreadsheet\Chart\DataSeriesValues; +use PhpOffice\PhpSpreadsheet\Chart\DataTable; use PhpOffice\PhpSpreadsheet\Chart\GridLines; use PhpOffice\PhpSpreadsheet\Chart\Layout; use PhpOffice\PhpSpreadsheet\Chart\Legend; @@ -137,7 +138,7 @@ class Chart break; case 'plotArea': - $plotAreaLayout = $XaxisLabel = $YaxisLabel = null; + $plotAreaLayout = $XaxisLabel = $YaxisLabel = $dataTable = null; $plotSeries = $plotAttributes = []; $catAxRead = false; $plotNoFill = false; @@ -358,6 +359,10 @@ class Chart } $plotAttributes = $this->readChartAttributes($chartDetail); + break; + case 'dTable': + $dataTable = $this->readDataTable($chartDetail); + break; } } @@ -365,6 +370,11 @@ class Chart $plotAreaLayout = new Layout(); } $plotArea = new PlotArea($plotAreaLayout, $plotSeries); + if ($dataTable !== null) { + $plotArea->setDataTable( + $dataTable + ); + } $this->setChartAttributes($plotAreaLayout, $plotAttributes); if ($plotNoFill) { $plotArea->setNoFill(true); @@ -1295,6 +1305,29 @@ class Chart return $plotAttributes; } + private function readDataTable(SimpleXMLElement $chartDetail): DataTable + { + $dataTable = new DataTable(); + $temp = self::getAttributeBoolean($chartDetail->showHorzBorder, 'val'); + if ($temp !== null) { + $dataTable->setShowHorizontalBorder($temp); + } + $temp = self::getAttributeBoolean($chartDetail->showVertBorder, 'val'); + if ($temp !== null) { + $dataTable->setShowVerticalBorder($temp); + } + $temp = self::getAttributeBoolean($chartDetail->showOutline, 'val'); + if ($temp !== null) { + $dataTable->setShowOutline($temp); + } + $temp = self::getAttributeBoolean($chartDetail->showKeys, 'val'); + if ($temp !== null) { + $dataTable->setShowKeys($temp); + } + + return $dataTable; + } + /** @param array $plotAttributes */ private function setChartAttributes(Layout $plotArea, array $plotAttributes): void { diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Chart.php b/src/PhpSpreadsheet/Writer/Xlsx/Chart.php index db8c0321c..1cbc38790 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Chart.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Chart.php @@ -7,6 +7,7 @@ use PhpOffice\PhpSpreadsheet\Chart\Chart as SpreadsheetChart; use PhpOffice\PhpSpreadsheet\Chart\ChartColor; use PhpOffice\PhpSpreadsheet\Chart\DataSeries; use PhpOffice\PhpSpreadsheet\Chart\DataSeriesValues; +use PhpOffice\PhpSpreadsheet\Chart\DataTable; use PhpOffice\PhpSpreadsheet\Chart\Layout; use PhpOffice\PhpSpreadsheet\Chart\Legend; use PhpOffice\PhpSpreadsheet\Chart\PlotArea; @@ -444,6 +445,10 @@ class Chart extends WriterPart $this->writeValueAxis($objWriter, $xAxisLabel, $chartType, $id2, $id1, $catIsMultiLevelSeries, $xAxis ?? new Axis()); } else { $this->writeCategoryAxis($objWriter, $xAxisLabel, $id1, $id2, $catIsMultiLevelSeries, $xAxis ?? new Axis()); + $dataTable = $plotArea->getDataTable(); + if ($dataTable !== null) { + $this->writeDataTable($objWriter, $dataTable); + } } $this->writeValueAxis($objWriter, $yAxisLabel, $chartType, $id1, $id2, $valIsMultiLevelSeries, $yAxis ?? new Axis()); @@ -482,6 +487,29 @@ class Chart extends WriterPart $objWriter->endElement(); // c:plotArea } + private function writeDataTable(XMLWriter $objWriter, DataTable $dataTable): void + { + $objWriter->startElement('c:dTable'); + + $objWriter->startElement('c:showHorzBorder'); + $objWriter->writeAttribute('val', $dataTable->getShowHorizontalBorder() ? '1' : '0'); + $objWriter->endElement(); + + $objWriter->startElement('c:showVertBorder'); + $objWriter->writeAttribute('val', $dataTable->getShowVerticalBorder() ? '1' : '0'); + $objWriter->endElement(); + + $objWriter->startElement('c:showOutline'); + $objWriter->writeAttribute('val', $dataTable->getShowOutline() ? '1' : '0'); + $objWriter->endElement(); + + $objWriter->startElement('c:showKeys'); + $objWriter->writeAttribute('val', $dataTable->getShowKeys() ? '1' : '0'); + $objWriter->endElement(); + + $objWriter->endElement(); // c:dTable + } + private function writeDataLabelsBool(XMLWriter $objWriter, string $name, ?bool $value): void { if ($value !== null) { diff --git a/tests/PhpSpreadsheetTests/Chart/DataTableTest.php b/tests/PhpSpreadsheetTests/Chart/DataTableTest.php new file mode 100644 index 000000000..f9448a866 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Chart/DataTableTest.php @@ -0,0 +1,173 @@ +getShowHorizontalBorder()); + self::assertTrue($dataTable->getShowVerticalBorder()); + self::assertTrue($dataTable->getShowOutline()); + self::assertTrue($dataTable->getShowKeys()); + $dataTable->setShowHorizontalBorder(false) + ->setShowVerticalBorder(false) + ->setShowOutline(false) + ->setShowKeys(false); + self::assertFalse($dataTable->getShowHorizontalBorder()); + self::assertFalse($dataTable->getShowVerticalBorder()); + self::assertFalse($dataTable->getShowOutline()); + self::assertFalse($dataTable->getShowKeys()); + } + + private function readCharts(XlsxReader $reader): void + { + $reader->setIncludeCharts(true); + } + + private function writeCharts(XlsxWriter $writer): void + { + $writer->setIncludeCharts(true); + } + + public function testCopyDataTable(): void + { + $spreadsheet = new Spreadsheet(); + // based on 33_Chart_create_area3. + $spreadsheet->getTheme() + ->setThemeColorName( + SpreadsheetTheme::COLOR_SCHEME_2013_2022_NAME + ); + $worksheet = $spreadsheet->getActiveSheet(); + $sheet2 = $spreadsheet->createSheet(); + $sheet2->setTitle('Sheet2'); + $worksheet->fromArray( + [ + ['', 2010, 2011, 2012], + ['Q1', 12, 15, 21], + ['Q2', 56, 73, 86], + ['Q3', 52, 61, 69], + ['Q4', 30, 32, 0], + ] + ); + + // Set the Labels for each data series we want to plot + // Datatype + // Cell reference for data + // Format Code + // Number of datapoints in series + // Data values + // Data Marker + $dataSeriesLabels = [ + new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_STRING, 'Worksheet!$B$1', null, 1), // 2010 + new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_STRING, 'Worksheet!$C$1', null, 1), // 2011 + new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_STRING, 'Worksheet!$D$1', null, 1), // 2012 + ]; + // Set the X-Axis Labels + // Datatype + // Cell reference for data + // Format Code + // Number of datapoints in series + // Data values + // Data Marker + $xAxisTickValues = [ + new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_STRING, 'Worksheet!$A$2:$A$5', null, 4), // Q1 to Q4 + ]; + // Set the Data values for each data series we want to plot + // Datatype + // Cell reference for data + // Format Code + // Number of datapoints in series + // Data values + // Data Marker + $dataSeriesValues = [ + new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_NUMBER, 'Worksheet!$B$2:$B$5', null, 4), + new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_NUMBER, 'Worksheet!$C$2:$C$5', null, 4), + new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_NUMBER, 'Worksheet!$D$2:$D$5', null, 4), + ]; + + // Build the dataseries + $series = new DataSeries( + DataSeries::TYPE_AREACHART, // plotType + DataSeries::GROUPING_PERCENT_STACKED, // plotGrouping + range(0, count($dataSeriesValues) - 1), // plotOrder + $dataSeriesLabels, // plotLabel + $xAxisTickValues, // plotCategory + $dataSeriesValues // plotValues + ); + + // Set the series in the plot area + $plotArea = new PlotArea(null, [$series]); + $plotArea->setDataTable(new DataTable()); + // No need for Legend if using DataTable + $legend = null; //new ChartLegend(ChartLegend::POSITION_TOPRIGHT, null, false); + + $title = new Title('Test %age-Stacked Area Chart'); + $yAxisLabel = new Title('Value ($k)'); + + // Create the chart + $chart = new Chart( + 'chart1', // name + $title, // title + $legend, // legend + $plotArea, // plotArea + true, // plotVisibleOnly + DataSeries::EMPTY_AS_GAP, // displayBlanksAs + null, // xAxisLabel + $yAxisLabel // yAxisLabel + ); + + // Set the position where the chart should appear in the worksheet + $chart->setTopLeftPosition('A1'); + $chart->setBottomRightPosition('H18'); + + // Add the chart to the worksheet + $sheet2->addChart($chart); + $spreadsheet->setActiveSheetIndex(1); + + // Save Excel 2007 file + /** @var callable */ + $callableReader = [$this, 'readCharts']; + /** @var callable */ + $callableWriter = [$this, 'writeCharts']; + $reloadedSpreadsheet = $this->writeAndReload( + $spreadsheet, + 'Xlsx', + $this->readCharts(...), + $this->writeCharts(...) + ); + $spreadsheet->disconnectWorksheets(); + + $sheet = $reloadedSpreadsheet->getActiveSheet(); + $charts2 = $sheet->getChartCollection(); + self::assertCount(1, $charts2); + $chart2 = $charts2[0]; + self::assertNotNull($chart2); + $plotArea = $chart2->getPlotArea(); + self::assertNotNull($plotArea); + $dtab = $plotArea->getDataTable(); + self::assertNotNull($dtab); + self::assertTrue($dtab->getShowHorizontalBorder()); + self::assertTrue($dtab->getShowVerticalBorder()); + self::assertTrue($dtab->getShowOutline()); + self::assertTrue($dtab->getShowKeys()); + + $reloadedSpreadsheet->disconnectWorksheets(); + } +} From db8917cd4b6f008c5b138ac2cc3e23369c8b96ee Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 11 Jun 2026 06:54:43 -0700 Subject: [PATCH 06/24] Tighten Up Writer Html Logic Fix #434, which went stale in 2018 and is now reopened. User reported a fatal error in Writer/Html. Regrettably, there is no example code/spreadsheet to illustrate the error. However, the area of code where the error happened is identified. Studying that, it was clear that the error could be avoided through the use of the nullsafe operator `?->` without any performance hit, while making the resulting code a little clearer. --- src/PhpSpreadsheet/Writer/Html.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/PhpSpreadsheet/Writer/Html.php b/src/PhpSpreadsheet/Writer/Html.php index 3678cf67b..fcb8a8ddb 100644 --- a/src/PhpSpreadsheet/Writer/Html.php +++ b/src/PhpSpreadsheet/Writer/Html.php @@ -1595,7 +1595,13 @@ class Html extends BaseWriter $origData2 = $cell->getValueString(); } } - $formatCode = $worksheet->getParentOrThrow()->getCellXfByIndex($cell->getXfIndex())->getNumberFormat()->getFormatCode(); + $style = $worksheet->getParent() + ?->getCellXfByIndexOrNull( + $cell->getXfIndex() + ); + $formatCode = $style + ?->getNumberFormat() + ->getFormatCode(); $cellData = NumberFormat::toFormattedString( $origData2, @@ -1606,9 +1612,9 @@ class Html extends BaseWriter if ($cellData === $origData) { $cellData = htmlspecialchars($cellData, Settings::htmlEntityFlags()); } - if ($worksheet->getParentOrThrow()->getCellXfByIndex($cell->getXfIndex())->getFont()->getSuperscript()) { + if (true === $style?->getFont()->getSuperscript()) { $cellData = '' . $cellData . ''; - } elseif ($worksheet->getParentOrThrow()->getCellXfByIndex($cell->getXfIndex())->getFont()->getSubscript()) { + } elseif (true === $style?->getFont()->getSubscript()) { $cellData = '' . $cellData . ''; } } From 99b0d93026854d1160504cbb10bf9bdad568d957 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:38:46 -0700 Subject: [PATCH 07/24] Update Changelog --- CHANGELOG.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a20ac200..793174876 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a ### Added -- Nothing yet. +- Navigate Cell as Cursor. [Issue #863](https://github.com/PHPOffice/PhpSpreadsheet/issues/863) [PR #4909](https://github.com/PHPOffice/PhpSpreadsheet/pull/4909) +- Chart DataTables. [Issue #413](https://github.com/PHPOffice/PhpSpreadsheet/issues/413) [PR #4911](https://github.com/PHPOffice/PhpSpreadsheet/pull/4911) ### Removed @@ -29,7 +30,9 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a ### Fixed -- Nothing yet. +- Small improvement for Radar Charts. [Issue #661](https://github.com/PHPOffice/PhpSpreadsheet/issues/661) [PR #4908](https://github.com/PHPOffice/PhpSpreadsheet/pull/4908) +- Allow Use of Multiple Ranges When Setting Styles. [Issue #411](https://github.com/PHPOffice/PhpSpreadsheet/issues/411) [PR #4910](https://github.com/PHPOffice/PhpSpreadsheet/pull/4910) +- Small change to Writer Html. [Issue #434](https://github.com/PHPOffice/PhpSpreadsheet/issues/434) [PR #4912](https://github.com/PHPOffice/PhpSpreadsheet/pull/4912) ## 2026-06-06 - 5.8.0 From 1876d6062f2fde0e1b95155e55213a93998c3c89 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:45:13 -0700 Subject: [PATCH 08/24] Permit Ignoring 'Misleading Format' Tag Building on PR #3508 and PR #4393, which allowed ignoring 5 different cell 'errors'. Adding this new one should have been dead easy, but MS, in its own inscrutable way, decided to complicate this by making the xml attribute require its very own namespace, unlike any of its relatives. There are supposed to be several ways to activate this tag. One is to enter a date in cell A1, then in cell B1 enter the formula `=A1`, and set B1's numeric format to `General`. --- src/PhpSpreadsheet/Cell/IgnoredErrors.php | 14 +++ src/PhpSpreadsheet/Reader/Xlsx.php | 27 +++++- src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php | 2 + src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php | 15 ++- .../Reader/Xlsx/IgnoredErrorTest.php | 88 ++++++++++++++++++ tests/data/Reader/XLSX/misleading.xlsx | Bin 0 -> 9901 bytes 6 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 tests/data/Reader/XLSX/misleading.xlsx diff --git a/src/PhpSpreadsheet/Cell/IgnoredErrors.php b/src/PhpSpreadsheet/Cell/IgnoredErrors.php index 0d88fe342..37b4de476 100644 --- a/src/PhpSpreadsheet/Cell/IgnoredErrors.php +++ b/src/PhpSpreadsheet/Cell/IgnoredErrors.php @@ -14,6 +14,8 @@ class IgnoredErrors private bool $evalError = false; + private bool $misleadingFormat = false; + public function setNumberStoredAsText(bool $value): self { $this->numberStoredAsText = $value; @@ -73,4 +75,16 @@ class IgnoredErrors { return $this->evalError; } + + public function setMisleadingFormat(bool $value): self + { + $this->misleadingFormat = $value; + + return $this; + } + + public function getMisleadingFormat(): bool + { + return $this->misleadingFormat; + } } diff --git a/src/PhpSpreadsheet/Reader/Xlsx.php b/src/PhpSpreadsheet/Reader/Xlsx.php index 4aa55b999..b2980f97d 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx.php +++ b/src/PhpSpreadsheet/Reader/Xlsx.php @@ -2661,6 +2661,8 @@ class Xlsx extends BaseReader $formulaRange = (string) ($attributes['formulaRange'] ?? ''); $twoDigitTextYear = (string) ($attributes['twoDigitTextYear'] ?? ''); $evalError = (string) ($attributes['evalError'] ?? ''); + $attributes2 = self::getAttributes($xml, Namespaces::MISLEADING_FORMAT); + $misleadingFormat = (string) ($attributes2['misleadingFormat'] ?? ''); if (!empty($sqref)) { $explodedSqref = explode(' ', $sqref); $pattern1 = '/^([A-Z]{1,3})([0-9]{1,7})(:([A-Z]{1,3})([0-9]{1,7}))?$/'; @@ -2682,19 +2684,34 @@ class Xlsx extends BaseReader continue; } if ($numberStoredAsText === '1') { - $sheet->getCell("$col$row")->getIgnoredErrors()->setNumberStoredAsText(true); + $sheet->getCell("$col$row") + ->getIgnoredErrors() + ->setNumberStoredAsText(true); } if ($formula === '1') { - $sheet->getCell("$col$row")->getIgnoredErrors()->setFormula(true); + $sheet->getCell("$col$row") + ->getIgnoredErrors() + ->setFormula(true); } if ($formulaRange === '1') { - $sheet->getCell("$col$row")->getIgnoredErrors()->setFormulaRange(true); + $sheet->getCell("$col$row") + ->getIgnoredErrors() + ->setFormulaRange(true); } if ($twoDigitTextYear === '1') { - $sheet->getCell("$col$row")->getIgnoredErrors()->setTwoDigitTextYear(true); + $sheet->getCell("$col$row") + ->getIgnoredErrors() + ->setTwoDigitTextYear(true); } if ($evalError === '1') { - $sheet->getCell("$col$row")->getIgnoredErrors()->setEvalError(true); + $sheet->getCell("$col$row") + ->getIgnoredErrors() + ->setEvalError(true); + } + if ($misleadingFormat === '1') { + $sheet->getCell("$col$row") + ->getIgnoredErrors() + ->setMisleadingFormat(true); } } } diff --git a/src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php b/src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php index 5d94f2fd4..1d51acc71 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php +++ b/src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php @@ -133,4 +133,6 @@ class Namespaces const FEATURE_PROPERTY_BAG = 'http://schemas.microsoft.com/office/spreadsheetml/2022/featurepropertybag'; const RELATIONSHIPS_FEATURE_PROPERTY_BAG = 'http://schemas.microsoft.com/office/2022/11/relationships/FeaturePropertyBag'; const STYLE_CHECKBOX_URI = '{C7286773-470A-42A8-94C5-96B5CB345126}'; + + const MISLEADING_FORMAT = 'http://schemas.microsoft.com/office/spreadsheetml/2018/08/main'; } diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php index 3a21c4154..4a448afcf 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php @@ -35,6 +35,8 @@ class Worksheet extends WriterPart private string $evalError = ''; + private string $misleadingFormat = ''; + private bool $explicitStyle0; private bool $useDynamicArrays = false; @@ -59,6 +61,7 @@ class Worksheet extends WriterPart $this->formulaRange = ''; $this->twoDigitTextYear = ''; $this->evalError = ''; + $this->misleadingFormat = ''; // Create XML writer $objWriter = null; if ($this->getParentWriter()->getUseDiskCaching()) { @@ -177,7 +180,13 @@ class Worksheet extends WriterPart } $objWriter->startElement('ignoredError'); $objWriter->writeAttribute('sqref', substr($cells, 1)); - $objWriter->writeAttribute($attr, '1'); + if ($attr === 'misleadingFormat') { + $prefix = 'x16r3'; + $objWriter->writeAttribute("xmlns:$prefix", Namespaces::MISLEADING_FORMAT); + $objWriter->writeAttribute("$prefix:$attr", '1'); + } else { + $objWriter->writeAttribute($attr, '1'); + } $objWriter->endElement(); } } @@ -190,6 +199,7 @@ class Worksheet extends WriterPart $this->writeIgnoredError($objWriter, $started, 'formulaRange', $this->formulaRange); $this->writeIgnoredError($objWriter, $started, 'twoDigitTextYear', $this->twoDigitTextYear); $this->writeIgnoredError($objWriter, $started, 'evalError', $this->evalError); + $this->writeIgnoredError($objWriter, $started, 'misleadingFormat', $this->misleadingFormat); if ($started) { $objWriter->endElement(); } @@ -1492,6 +1502,9 @@ class Worksheet extends WriterPart if ($worksheet->getCell($coord)->getIgnoredErrors()->getEvalError()) { $this->evalError .= " $coord"; } + if ($worksheet->getCell($coord)->getIgnoredErrors()->getMisleadingFormat()) { + $this->misleadingFormat .= " $coord"; + } $this->writeCell($objWriter, $worksheet, $coord, $aFlippedStringTable); } } diff --git a/tests/PhpSpreadsheetTests/Reader/Xlsx/IgnoredErrorTest.php b/tests/PhpSpreadsheetTests/Reader/Xlsx/IgnoredErrorTest.php index 36e3b3075..409dac81e 100644 --- a/tests/PhpSpreadsheetTests/Reader/Xlsx/IgnoredErrorTest.php +++ b/tests/PhpSpreadsheetTests/Reader/Xlsx/IgnoredErrorTest.php @@ -13,6 +13,8 @@ class IgnoredErrorTest extends AbstractFunctional { private const FILENAME = 'tests/data/Reader/XLSX/ignoreerror.xlsx'; + private const FILENAME2 = 'tests/data/Reader/XLSX/misleading.xlsx'; + public function testIgnoredError(): void { $reader = new Xlsx(); @@ -69,4 +71,90 @@ class IgnoredErrorTest extends AbstractFunctional self::assertTrue($sheet->getCell('A2')->getIgnoredErrors()->getNumberStoredAsText()); $spreadsheet->disconnectWorksheets(); } + + public function testMisleading(): void + { + $reader = new Xlsx(); + $originalSpreadsheet = $reader->load(self::FILENAME2); + $sheet = $originalSpreadsheet->getActiveSheet(); + self::assertTrue( + $sheet->getCell('C1') + ->getIgnoredErrors() + ->getMisleadingFormat() + ); + self::assertFalse( + $sheet->getCell('C2') + ->getIgnoredErrors() + ->getMisleadingFormat() + ); + self::assertFalse( + $sheet->getCell('C3') + ->getIgnoredErrors() + ->getMisleadingFormat() + ); + self::assertTrue( + $sheet->getCell('D1') + ->getIgnoredErrors() + ->getNumberStoredAsText() + ); + self::assertFalse( + $sheet->getCell('D2') + ->getIgnoredErrors() + ->getNumberStoredAsText() + ); + self::assertFalse( + $sheet->getCell('D3') + ->getIgnoredErrors() + ->getNumberStoredAsText() + ); + + $sheet->getCell('C1') + ->getIgnoredErrors() + ->setMisleadingFormat(false); + $sheet->getCell('D1') + ->getIgnoredErrors() + ->setNumberStoredAsText(false); + $sheet->getCell('C2') + ->getIgnoredErrors() + ->setMisleadingFormat(true); + $sheet->getCell('D2') + ->getIgnoredErrors() + ->setNumberStoredAsText(true); + + $spreadsheet = $this->writeAndReload($originalSpreadsheet, 'Xlsx'); + $originalSpreadsheet->disconnectWorksheets(); + $sheet = $spreadsheet->getActiveSheet(); + self::assertFalse( + $sheet->getCell('C1') + ->getIgnoredErrors() + ->getMisleadingFormat() + ); + self::assertTrue( + $sheet->getCell('C2') + ->getIgnoredErrors() + ->getMisleadingFormat() + ); + self::assertFalse( + $sheet->getCell('C3') + ->getIgnoredErrors() + ->getMisleadingFormat() + ); + self::assertFalse( + $sheet->getCell('D1') + ->getIgnoredErrors() + ->getNumberStoredAsText() + ); + self::assertTrue( + $sheet->getCell('D2') + ->getIgnoredErrors() + ->getNumberStoredAsText() + ); + self::assertFalse( + $sheet->getCell('D3') + ->getIgnoredErrors() + ->getNumberStoredAsText() + ); + + $spreadsheet->disconnectWorksheets(); + } } diff --git a/tests/data/Reader/XLSX/misleading.xlsx b/tests/data/Reader/XLSX/misleading.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..2ef724d989b4b65a8d7ee67074cfe3ff23e418cb GIT binary patch literal 9901 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 Date: Tue, 16 Jun 2026 20:08:46 -0700 Subject: [PATCH 09/24] Avoid Hard-Coding Some Chart Xml Attributes Writer/Xlsx/Chart hard-codes a lot of output values. While nobody has reported a problem involving these, it makes sense to me to copy those values over from what Xlsx/Reader/Chart read, rather than hard-coding them. This involves adding some new properties to Chart and Layout, all, naturally, initialized to the values that we have been hard-coding. - Chart\date1904 - Chart\lang - Chart\pageMargins - Chart\pageSetup - Layout\bodyPr With one exception, I have not investigated any of the new properties in depth. That may come in time. The one I did look at is `date1904`. Chances are that it should match the equivalent spreadsheet setting, but it won't matter if there are no dates on your chart, and it often won't matter even if you do. It may well matter if you are using a `date axis`. While looking into that, it became apparent that some Shared Date conversions need to be a bit more flexible, specifying an optional `calendar` parameter rather than relying on the Spreadsheet `calendar` (which is not accessible while processing the chart) or the Shared\Date `calendar` (which is accessible). --- .../33_Chart_create_line_dateaxis.php | 18 ++- src/PhpSpreadsheet/Cell/Cell.php | 20 ++- src/PhpSpreadsheet/Chart/Chart.php | 119 +++++++++++++++++- src/PhpSpreadsheet/Chart/Layout.php | 80 +++++++++++- src/PhpSpreadsheet/Reader/Xlsx/Chart.php | 62 ++++++--- src/PhpSpreadsheet/Shared/Date.php | 56 +++++---- src/PhpSpreadsheet/Writer/Xlsx/Chart.php | 57 +++++---- .../PhpSpreadsheetTests/Chart/CopyXmlTest.php | 42 +++++++ .../Chart/Issue2931Test.php | 4 +- tests/PhpSpreadsheetTests/Shared/DateTest.php | 34 +++++ 10 files changed, 415 insertions(+), 77 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Chart/CopyXmlTest.php diff --git a/samples/Chart33a/33_Chart_create_line_dateaxis.php b/samples/Chart33a/33_Chart_create_line_dateaxis.php index 86415c4aa..7cdefbba7 100644 --- a/samples/Chart33a/33_Chart_create_line_dateaxis.php +++ b/samples/Chart33a/33_Chart_create_line_dateaxis.php @@ -10,6 +10,10 @@ use PhpOffice\PhpSpreadsheet\Spreadsheet; require __DIR__ . '/../Header.php'; /** @var \PhpOffice\PhpSpreadsheet\Helper\Sample $helper */ $spreadsheet = new Spreadsheet(); +// following stmt can be commented in/out to test both calendars +$spreadsheet->setExcelCalendar(SharedDate::CALENDAR_MAC_1904); +$calendar = $spreadsheet->getExcelCalendar(); +$use1904 = $calendar === SharedDate::CALENDAR_MAC_1904; $dataSheet = $spreadsheet->getActiveSheet(); $dataSheet->setTitle('Data'); // changed data to simulate a trend chart - Xaxis are dates; Yaxis are 3 meausurements from each date @@ -185,6 +189,9 @@ $chart = new Chart( $xAxis, // xAxis $yAxis, // yAxis ); +if ($use1904) { + $chart->setDate1904(true); +} // Set the position of the chart in the chart sheet $chart->setTopLeftPosition('A1'); @@ -275,7 +282,7 @@ $xAxis->setAxisNumberProperties(Properties::FORMAT_CODE_DATE_ISO8601); $xAxis->setAxisType('dateAx'); // dateAx available ONLY for LINECHART, not SCATTERCHART // measure the time span in Quarters, of data. -$dateMinMax = dateRange(8, $spreadsheet); // array 'min'=>earliest date of first Q, 'max'=>latest date of final Q +$dateMinMax = dateRange(8, $spreadsheet, $calendar); // array 'min'=>earliest date of first Q, 'max'=>latest date of final Q // change xAxis tick marks to match Qtr boundaries $nQtrs = sprintf('%3.2f', (($dateMinMax['max'] - $dateMinMax['min']) / 30.5) / 4); @@ -321,6 +328,9 @@ $chart = new Chart( $xAxis, // xAxis $yAxis, // yAxis ); +if ($use1904) { + $chart->setDate1904(true); +} // Set the position of the chart in the chart sheet below the first chart $chart->setTopLeftPosition('A13'); @@ -339,7 +349,7 @@ $helper->write($spreadsheet, __FILE__, ['Xlsx'], true, resetActiveSheet: false); $spreadsheet->disconnectWorksheets(); /** @return array{'min': float|int, 'max': float|int} */ -function dateRange(int $nrows, Spreadsheet $wrkbk): array +function dateRange(int $nrows, Spreadsheet $wrkbk, int $calendar): array { $dataSheet = $wrkbk->getSheetByNameOrThrow('Data'); @@ -357,7 +367,7 @@ function dateRange(int $nrows, Spreadsheet $wrkbk): array $qtr = intdiv($startMonth, 3) + (($startMonth % 3 > 0) ? 1 : 0); $qtrStartMonth = sprintf('%02d', 1 + (($qtr - 1) * 3)); $qtrStartStr = "$startYr-$qtrStartMonth-01"; - $ExcelQtrStartDateVal = SharedDate::convertIsoDate($qtrStartStr); + $ExcelQtrStartDateVal = SharedDate::convertIsoDate($qtrStartStr, $calendar); // end the xaxis at the end of the quarter of the last date /** @var string */ @@ -377,7 +387,7 @@ function dateRange(int $nrows, Spreadsheet $wrkbk): array } $lastDOM = $lastDOMDate->format('t'); $qtrEndStr = "$lastYr-$qtrEndMonth-$lastDOM"; - $ExcelQtrEndDateVal = SharedDate::convertIsoDate($qtrEndStr); + $ExcelQtrEndDateVal = SharedDate::convertIsoDate($qtrEndStr, $calendar); $minMaxDates = ['min' => $ExcelQtrStartDateVal, 'max' => $ExcelQtrEndDateVal]; diff --git a/src/PhpSpreadsheet/Cell/Cell.php b/src/PhpSpreadsheet/Cell/Cell.php index ca13e824f..5fdccfc8d 100644 --- a/src/PhpSpreadsheet/Cell/Cell.php +++ b/src/PhpSpreadsheet/Cell/Cell.php @@ -191,12 +191,22 @@ class Cell implements Stringable public function getFormattedValue(): string { $currentCalendar = SharedDate::getExcelCalendar(); - SharedDate::setExcelCalendar($this->getWorksheet()->getParent()?->getExcelCalendar()); - $formattedValue = (string) NumberFormat::toFormattedString( - $this->getCalculatedValueString(), - (string) $this->getStyle()->getNumberFormat()->getFormatCode(true) + SharedDate::setExcelCalendar( + $this->getWorksheet() + ->getParent() + ?->getExcelCalendar() ); - SharedDate::setExcelCalendar($currentCalendar); + + try { + $formattedValue = NumberFormat::toFormattedString( + $this->getCalculatedValueString(), + (string) $this->getStyle() + ->getNumberFormat() + ->getFormatCode(true) + ); + } finally { + SharedDate::setExcelCalendar($currentCalendar); + } return $formattedValue; } diff --git a/src/PhpSpreadsheet/Chart/Chart.php b/src/PhpSpreadsheet/Chart/Chart.php index e750edc21..6288ea1d7 100644 --- a/src/PhpSpreadsheet/Chart/Chart.php +++ b/src/PhpSpreadsheet/Chart/Chart.php @@ -110,6 +110,37 @@ class Chart private bool $roundedCorners = false; + private bool $date1904 = false; + + private string $lang = 'en-GB'; + + /** @var array{ + * b?: numeric-string, + * l?: numeric-string, + * r?: numeric-string, + * t?: numeric-string, + * header?: numeric-string, + * footer?: numeric-string, + * } + */ + private array $pageMargins = [ + 'b' => '0.75', + 'l' => '0.7', + 'r' => '0.7', + 't' => '0.75', + 'header' => '0.3', + 'footer' => '0.3', + ]; + + /** @var array{ + * paperSize?: string, + * orientation?: string, + * } + */ + private array $pageSetup = [ + 'orientation' => 'portrait', + ]; + private GridLines $borderLines; private ChartColor $fillColor; @@ -680,9 +711,11 @@ class Chart return $this->autoTitleDeleted; } - public function setAutoTitleDeleted(bool $autoTitleDeleted): self + public function setAutoTitleDeleted(?bool $autoTitleDeleted): self { - $this->autoTitleDeleted = $autoTitleDeleted; + if (is_bool($autoTitleDeleted)) { + $this->autoTitleDeleted = $autoTitleDeleted; + } return $this; } @@ -725,6 +758,34 @@ class Chart return $this; } + public function getDate1904(): bool + { + return $this->date1904; + } + + public function setDate1904(?bool $date1904): self + { + if ($date1904 !== null) { + $this->date1904 = $date1904; + } + + return $this; + } + + public function getLang(): string + { + return $this->lang; + } + + public function setLang(?string $lang): self + { + if ($lang !== null && $lang !== '') { + $this->lang = $lang; + } + + return $this; + } + public function getBorderLines(): GridLines { return $this->borderLines; @@ -782,4 +843,58 @@ class Chart $this->borderLines = clone $this->borderLines; $this->fillColor = clone $this->fillColor; } + + /** @return array{ + * b?: numeric-string, + * l?: numeric-string, + * r?: numeric-string, + * t?: numeric-string, + * header?: numeric-string, + * footer?: numeric-string, + * } + */ + public function getPageMargins(): array + { + return $this->pageMargins; + } + + /** @param mixed $pageMargins expecting array matching $this->pageMargins */ + public function setPageMargins(mixed $pageMargins): self + { + if (is_array($pageMargins)) { + foreach (['b', 'l', 'r', 't', 'header', 'footer'] as $key) { + $value = $pageMargins[$key] ?? null; + if (is_string($value) && is_numeric($value)) { + $this->pageMargins[$key] = "$value"; + } + } + } + + return $this; + } + + /** @return array{ + * paperSize?: string, + * orientation?: string, + * } + */ + public function getPageSetup(): array + { + return $this->pageSetup; + } + + /** @param mixed $pageSetup expecting array matching $this->pageSetup */ + public function setPageSetup(mixed $pageSetup): self + { + if (is_array($pageSetup)) { + foreach (['paperSize', 'orientation'] as $key) { + $value = $pageSetup[$key] ?? null; + if (is_string($value)) { + $this->pageSetup[$key] = "$value"; + } + } + } + + return $this; + } } diff --git a/src/PhpSpreadsheet/Chart/Layout.php b/src/PhpSpreadsheet/Chart/Layout.php index 95e113d5e..6b90b7b58 100644 --- a/src/PhpSpreadsheet/Chart/Layout.php +++ b/src/PhpSpreadsheet/Chart/Layout.php @@ -99,6 +99,26 @@ class Layout private ?Properties $labelEffects = null; + /** @var array{ + * vertOverflow?: string, + * horzOverflow?: string, + * wrap?: string, + * lIns?: numeric-string, + * tIns?: numeric-string, + * rIns?: numeric-string, + * bIns?: numeric-string, + * anchor?: string, + * } + */ + private array $bodyPr = [ + 'wrap' => 'square', + 'lIns' => '38100', + 'tIns' => '19050', + 'rIns' => '38100', + 'bIns' => '19050', + 'anchor' => 'ctr', + ]; + /** * Create a new Layout. * @@ -106,7 +126,22 @@ class Layout */ public function __construct(array $layout = []) { - /** @var array{layoutTarget?: string, xMode?: string, yMode?: string, x?: float, y?: float, w?:float, h?:float, dLblPos?: string, labelFont?: ?mixed, labelFontColor?: ?mixed, labelEffects?: ?mixed, numFmtCode?: string} $layout */ + /** @var array{ + * layoutTarget?: string, + * xMode?: string, + * yMode?: string, + * x?: float, + * y?: float, + * w?:float, + * h?:float, + * dLblPos?: string, + * labelFont?: ?mixed, + * labelFontColor?: ?mixed, + * labelEffects?: ?mixed, + * numFmtCode?: string, + * bodyPr?: mixed, + * } $layout + */ if (isset($layout['layoutTarget'])) { $this->layoutTarget = $layout['layoutTarget']; } @@ -156,6 +191,10 @@ class Layout if ($labelEffects instanceof Properties) { $this->labelEffects = $labelEffects; } + $bodyPr = $layout['bodyPr'] ?? null; + if (is_array($bodyPr)) { + $this->setBodyPr($bodyPr); + } } /** @param mixed[] $layout */ @@ -523,6 +562,45 @@ class Layout return $this; } + /** @return array{ + * vertOverflow?: string, + * horzOverflow?: string, + * wrap?: string, + * lIns?: numeric-string, + * tIns?: numeric-string, + * rIns?: numeric-string, + * bIns?: numeric-string, + * anchor?: string, + * } + */ + public function getBodyPr(): array + { + return $this->bodyPr; + } + + /** + * @param mixed $bodyPr expect array matching $this->bodyPr + */ + public function setBodyPr(mixed $bodyPr): self + { + if (is_array($bodyPr)) { + foreach (['vertOverflow', 'horzOverflow', 'wrap', 'anchor'] as $key) { + $value = $bodyPr[$key] ?? null; + if (is_string($value)) { + $this->bodyPr[$key] = "$value"; + } + } + foreach (['lIns', 'tIns', 'rIns', 'bIns'] as $key) { + $value = $bodyPr[$key] ?? null; + if (is_string($value) && is_numeric($value)) { + $this->bodyPr[$key] = "$value"; + } + } + } + + return $this; + } + /** * Implement PHP __clone to create a deep clone, not just a shallow copy. */ diff --git a/src/PhpSpreadsheet/Reader/Xlsx/Chart.php b/src/PhpSpreadsheet/Reader/Xlsx/Chart.php index 2b6dfb687..d00a7aed0 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx/Chart.php +++ b/src/PhpSpreadsheet/Reader/Xlsx/Chart.php @@ -92,13 +92,26 @@ class Chart $chartFillColor = null; $gradientArray = []; $gradientLin = null; - $roundedCorners = false; + $roundedCorners = null; + $date1904 = null; + $lang = null; $gapWidth = null; $useUpBars = null; $useDownBars = null; $noBorder = false; + $pageMargins = []; + $pageSetup = []; foreach ($chartElementsC as $chartElementKey => $chartElement) { switch ($chartElementKey) { + case 'printSettings': + if (isset($chartElement->pageMargins)) { + $pageMargins = current((array) $chartElement->pageMargins->attributes()); + } + if (isset($chartElement->pageSetup)) { + $pageSetup = current((array) $chartElement->pageSetup->attributes()); + } + + break; case 'spPr': $children = $chartElementsC->spPr->children($this->aNamespace); if (isset($children->noFill)) { @@ -117,16 +130,22 @@ class Chart break; case 'roundedCorners': - /** @var bool $roundedCorners */ $roundedCorners = self::getAttributeBoolean($chartElementsC->roundedCorners, 'val'); + break; + case 'date1904': + $date1904 = self::getAttributeBoolean($chartElementsC->date1904, 'val'); + + break; + case 'lang': + $lang = self::getAttributeString($chartElementsC->lang, 'val'); + break; case 'chart': foreach ($chartElement as $chartDetailsKey => $chartDetails) { $chartDetails = Xlsx::testSimpleXml($chartDetails); switch ($chartDetailsKey) { case 'autoTitleDeleted': - /** @var bool $autoTitleDeleted */ $autoTitleDeleted = self::getAttributeBoolean($chartElementsC->chart->autoTitleDeleted, 'val'); break; @@ -482,23 +501,18 @@ class Chart if ($chartBorderLines !== null) { $chart->setBorderLines($chartBorderLines); } - $chart->setNoBorder($noBorder); - $chart->setRoundedCorners($roundedCorners); - if (is_bool($autoTitleDeleted)) { - $chart->setAutoTitleDeleted($autoTitleDeleted); - } - if (is_int($rotX)) { - $chart->setRotX($rotX); - } - if (is_int($rotY)) { - $chart->setRotY($rotY); - } - if (is_int($rAngAx)) { - $chart->setRAngAx($rAngAx); - } - if (is_int($perspective)) { - $chart->setPerspective($perspective); - } + $chart + ->setNoBorder($noBorder) + ->setRoundedCorners($roundedCorners) + ->setDate1904($date1904) + ->setLang($lang) + ->setPageMargins($pageMargins) + ->setPageSetup($pageSetup) + ->setAutoTitleDeleted($autoTitleDeleted) + ->setRotX($rotX) + ->setRotY($rotY) + ->setRAngAx($rAngAx) + ->setPerspective($perspective); return $chart; } @@ -1299,6 +1313,9 @@ class Chart $plotAttributes['labelEffects'] = $labelEffects; } } + if (isset($txpr->bodyPr)) { + $plotAttributes['bodyPr'] = current((array) $txpr->bodyPr->attributes()); + } } } @@ -1366,6 +1383,11 @@ class Chart /** @var ?Font $plotAttributeValue */ $plotArea->setLabelFont($plotAttributeValue); + break; + case 'bodyPr': + /** @var mixed $plotAttributeValue */ + $plotArea->setBodyPr($plotAttributeValue); + break; } } diff --git a/src/PhpSpreadsheet/Shared/Date.php b/src/PhpSpreadsheet/Shared/Date.php index 2e2df9ada..3746cd46b 100644 --- a/src/PhpSpreadsheet/Shared/Date.php +++ b/src/PhpSpreadsheet/Shared/Date.php @@ -160,7 +160,7 @@ class Date * serialized timestamp. * See https://en.wikipedia.org/wiki/ISO_8601 for details of the ISO-8601 standard format. */ - public static function convertIsoDate(mixed $value): float|int + public static function convertIsoDate(mixed $value, ?int $calendar = null): float|int { if (!is_string($value)) { throw new Exception('Non-string value supplied for Iso Date conversion'); @@ -173,7 +173,7 @@ class Date throw new Exception("Invalid string $value supplied for datatype Date"); } - $newValue = self::dateTimeToExcel($date); + $newValue = self::dateTimeToExcel($date, $calendar); if (preg_match('/^\s*\d?\d:\d\d(:\d\d([.]\d+)?)?\s*(am|pm)?\s*$/i', $value) == 1) { $newValue = fmod($newValue, 1.0); @@ -194,16 +194,17 @@ class Date * * @return DateTime PHP date/time object */ - public static function excelToDateTimeObject(float|int $excelTimestamp, null|DateTimeZone|string $timeZone = null): DateTime + public static function excelToDateTimeObject(float|int $excelTimestamp, null|DateTimeZone|string $timeZone = null, ?int $calendar = null): DateTime { + $calendar ??= self::$excelCalendar; $timeZone = ($timeZone === null) ? self::getDefaultTimezone() : self::validateTimeZone($timeZone); if (Functions::getCompatibilityMode() == Functions::COMPATIBILITY_EXCEL) { - if ($excelTimestamp < 1 && self::$excelCalendar === self::CALENDAR_WINDOWS_1900) { + if ($excelTimestamp < 1 && $calendar === self::CALENDAR_WINDOWS_1900) { // Unix timestamp base date $baseDate = new DateTime('1970-01-01', $timeZone); } else { // MS Excel calendar base dates - if (self::$excelCalendar == self::CALENDAR_WINDOWS_1900) { + if ($calendar == self::CALENDAR_WINDOWS_1900) { // Allow adjustment for 1900 Leap Year in MS Excel $baseDate = ($excelTimestamp < 60) ? new DateTime('1899-12-31', $timeZone) : new DateTime('1899-12-30', $timeZone); } else { @@ -252,9 +253,9 @@ class Date * * @return int Unix timetamp for this date/time */ - public static function excelToTimestamp($excelTimestamp, $timeZone = null): int + public static function excelToTimestamp($excelTimestamp, $timeZone = null, ?int $calendar = null): int { - $dto = self::excelToDateTimeObject($excelTimestamp, $timeZone); + $dto = self::excelToDateTimeObject($excelTimestamp, $timeZone, $calendar); self::roundMicroseconds($dto); return (int) $dto->format('U'); @@ -269,14 +270,16 @@ class Date * @return false|float Excel date/time value * or boolean FALSE on failure */ - public static function PHPToExcel(mixed $dateValue) + public static function PHPToExcel(mixed $dateValue, ?int $calendar = null) { if ((is_object($dateValue)) && ($dateValue instanceof DateTimeInterface)) { - return self::dateTimeToExcel($dateValue); - } elseif (is_numeric($dateValue)) { - return self::timestampToExcel($dateValue); - } elseif (is_string($dateValue)) { - return self::stringToExcel($dateValue); + return self::dateTimeToExcel($dateValue, $calendar); + } + if (is_numeric($dateValue)) { + return self::timestampToExcel($dateValue, $calendar); + } + if (is_string($dateValue)) { + return self::stringToExcel($dateValue, $calendar); } return false; @@ -289,7 +292,7 @@ class Date * * @return float MS Excel serialized date/time value */ - public static function dateTimeToExcel(DateTimeInterface $dateValue): float + public static function dateTimeToExcel(DateTimeInterface $dateValue, ?int $calendar = null): float { $seconds = (float) sprintf('%d.%06d', $dateValue->format('s'), $dateValue->format('u')); @@ -299,7 +302,8 @@ class Date (int) $dateValue->format('d'), (int) $dateValue->format('H'), (int) $dateValue->format('i'), - $seconds + $seconds, + $calendar ); } @@ -312,13 +316,13 @@ class Date * * @return false|float MS Excel serialized date/time value */ - public static function timestampToExcel($unixTimestamp): bool|float + public static function timestampToExcel($unixTimestamp, ?int $calendar = null): bool|float { if (!is_numeric($unixTimestamp)) { return false; } - return self::dateTimeToExcel(new DateTime('@' . $unixTimestamp)); + return self::dateTimeToExcel(new DateTime('@' . $unixTimestamp), $calendar); } /** @@ -326,9 +330,10 @@ class Date * * @return float Excel date/time value */ - public static function formattedPHPToExcel(int $year, int $month, int $day, int $hours = 0, int $minutes = 0, float|int $seconds = 0): float + public static function formattedPHPToExcel(int $year, int $month, int $day, int $hours = 0, int $minutes = 0, float|int $seconds = 0, ?int $calendar = null): float { - if (self::$excelCalendar == self::CALENDAR_WINDOWS_1900) { + $calendar ??= self::$excelCalendar; + if ($calendar === self::CALENDAR_WINDOWS_1900) { // // Fudge factor for the erroneous fact that the year 1900 is treated as a Leap Year in MS Excel // This affects every date following 28th February 1900 @@ -474,7 +479,7 @@ class Date * * @return false|float Excel date/time serial value */ - public static function stringToExcel(string $dateValue): bool|float + public static function stringToExcel(string $dateValue, ?int $calendar = null): bool|float { if (strlen($dateValue) < 2) { return false; @@ -483,7 +488,16 @@ class Date return false; } - $dateValueNew = DateTimeExcel\DateValue::fromString($dateValue); + $hold = self::$excelCalendar; + + try { + if ($calendar !== null) { + self::$excelCalendar = $calendar; + } + $dateValueNew = DateTimeExcel\DateValue::fromString($dateValue); + } finally { + self::$excelCalendar = $hold; + } if (!is_float($dateValueNew)) { return false; diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Chart.php b/src/PhpSpreadsheet/Writer/Xlsx/Chart.php index a6fc7d07d..d16b42354 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Chart.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Chart.php @@ -53,10 +53,10 @@ class Chart extends WriterPart $objWriter->writeAttribute('xmlns:r', Namespaces::SCHEMA_OFFICE_DOCUMENT); $objWriter->startElement('c:date1904'); - $objWriter->writeAttribute('val', '0'); + $objWriter->writeAttribute('val', $chart->getDate1904() ? '1' : '0'); $objWriter->endElement(); $objWriter->startElement('c:lang'); - $objWriter->writeAttribute('val', 'en-GB'); + $objWriter->writeAttribute('val', $chart->getLang()); $objWriter->endElement(); $objWriter->startElement('c:roundedCorners'); $objWriter->writeAttribute('val', $chart->getRoundedCorners() ? '1' : '0'); @@ -72,7 +72,6 @@ class Chart extends WriterPart $objWriter->writeAttribute('val', (string) (int) $chart->getAutoTitleDeleted()); $objWriter->endElement(); - $objWriter->startElement('c:view3D'); $surface2D = false; $plotArea = $chart->getPlotArea(); if ($plotArea !== null) { @@ -85,11 +84,14 @@ class Chart extends WriterPart } } } + $this->view3DStarted = false; $this->writeView3D($objWriter, $chart->getRotX(), 'c:rotX', $surface2D, 90); $this->writeView3D($objWriter, $chart->getRotY(), 'c:rotY', $surface2D); $this->writeView3D($objWriter, $chart->getRAngAx(), 'c:rAngAx', $surface2D); $this->writeView3D($objWriter, $chart->getPerspective(), 'c:perspective', $surface2D); - $objWriter->endElement(); // view3D + if ($this->view3DStarted) { + $objWriter->endElement(); // view3D + } $this->writePlotArea($objWriter, $chart->getPlotArea(), $chart->getXAxisLabel(), $chart->getYAxisLabel(), $chart->getChartAxisX(), $chart->getChartAxisY()); @@ -123,7 +125,7 @@ class Chart extends WriterPart $this->writeEffects($objWriter, $borderLines); $objWriter->endElement(); // c:spPr - $this->writePrintSettings($objWriter); + $this->writePrintSettings($objWriter, $chart); $objWriter->endElement(); // c:chartSpace @@ -131,12 +133,18 @@ class Chart extends WriterPart return $objWriter->getData(); } + private bool $view3DStarted = false; + private function writeView3D(XMLWriter $objWriter, ?int $value, string $tag, bool $surface2D, int $default = 0): void { if ($value === null && $surface2D) { $value = $default; } if ($value !== null) { + if (!$this->view3DStarted) { + $objWriter->startElement('c:view3D'); + $this->view3DStarted = true; + } $objWriter->startElement($tag); $objWriter->writeAttribute('val', "$value"); $objWriter->endElement(); @@ -549,12 +557,12 @@ class Chart extends WriterPart $objWriter->startElement('c:txPr'); $objWriter->startElement('a:bodyPr'); - $objWriter->writeAttribute('wrap', 'square'); - $objWriter->writeAttribute('lIns', '38100'); - $objWriter->writeAttribute('tIns', '19050'); - $objWriter->writeAttribute('rIns', '38100'); - $objWriter->writeAttribute('bIns', '19050'); - $objWriter->writeAttribute('anchor', 'ctr'); + $bodyPr = $chartLayout->getBodyPr(); + foreach (['vertOverflow', 'horzOverflow', 'wrap', 'lIns', 'tIns', 'rIns', 'bIns', 'anchor'] as $key) { + if (isset($bodyPr[$key])) { + $objWriter->writeAttribute($key, $bodyPr[$key]); + } + } $objWriter->startElement('a:spAutoFit'); $objWriter->endElement(); // a:spAutoFit $objWriter->endElement(); // a:bodyPr @@ -1716,27 +1724,32 @@ class Chart extends WriterPart /** * Write Printer Settings. */ - private function writePrintSettings(XMLWriter $objWriter): void + private function writePrintSettings(XMLWriter $objWriter, SpreadsheetChart $chart): void { $objWriter->startElement('c:printSettings'); $objWriter->startElement('c:headerFooter'); $objWriter->endElement(); + $pageMargins = $chart->getPageMargins(); $objWriter->startElement('c:pageMargins'); - $objWriter->writeAttribute('footer', '0.3'); - $objWriter->writeAttribute('header', '0.3'); - $objWriter->writeAttribute('r', '0.7'); - $objWriter->writeAttribute('l', '0.7'); - $objWriter->writeAttribute('t', '0.75'); - $objWriter->writeAttribute('b', '0.75'); - $objWriter->endElement(); + foreach (['b', 'l', 'r', 't', 'header', 'footer'] as $key) { + if (array_key_exists($key, $pageMargins)) { + $objWriter->writeAttribute($key, $pageMargins[$key]); + } + } + $objWriter->endElement(); // c:pageMargins + $pageSetup = $chart->getPageSetup(); $objWriter->startElement('c:pageSetup'); - $objWriter->writeAttribute('orientation', 'portrait'); - $objWriter->endElement(); + foreach (['paperSize', 'orientation'] as $key) { + if (array_key_exists($key, $pageSetup)) { + $objWriter->writeAttribute($key, $pageSetup[$key]); + } + } + $objWriter->endElement(); // c:pageSetup - $objWriter->endElement(); + $objWriter->endElement(); // c:printSettings } private function writeEffects(XMLWriter $objWriter, Properties $yAxis): void diff --git a/tests/PhpSpreadsheetTests/Chart/CopyXmlTest.php b/tests/PhpSpreadsheetTests/Chart/CopyXmlTest.php new file mode 100644 index 000000000..6921cd27f --- /dev/null +++ b/tests/PhpSpreadsheetTests/Chart/CopyXmlTest.php @@ -0,0 +1,42 @@ +setIncludeCharts(true); + $spreadsheet = $reader->load($infile); + $sheet = $spreadsheet->getSheetByNameOrThrow('Charts'); + $charts = $sheet->getChartCollection(); + self::assertCount(1, $charts); + $chart = $charts[0] ?? null; + self::assertInstanceOf(Chart::class, $chart); + + $writer = new XlsxWriter($spreadsheet); + $writer->setIncludeCharts(true); + $writer = new XlsxWriter($spreadsheet); + $writer->setIncludeCharts(true); + $writerChart = new XlsxWriter\Chart($writer); + $data = $writerChart->writeChart($chart); + //echo $data; + self::assertStringContainsString('', $data, 'From input even though same as default'); + self::assertStringContainsString('', $data, 'From input, different from default'); + self::assertStringNotContainsString('view3D', $data, 'No empty view3D tag'); + self::assertStringContainsString('', $data, 'A couple of extra attributes'); + self::assertStringContainsString('', $data, 'Some different values plus re-shuffling'); + self::assertStringContainsString('', $data, 'An extra attribute'); + + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Chart/Issue2931Test.php b/tests/PhpSpreadsheetTests/Chart/Issue2931Test.php index 2e1bd9d81..c2357e902 100644 --- a/tests/PhpSpreadsheetTests/Chart/Issue2931Test.php +++ b/tests/PhpSpreadsheetTests/Chart/Issue2931Test.php @@ -81,7 +81,7 @@ class Issue2931Test extends TestCase '', ]; $expectedXml3D = [ - '', + 'c:view3D', // empty view3d no longer generated ]; $expectedXmlNoX = [ 'c:grouping', @@ -102,7 +102,7 @@ class Issue2931Test extends TestCase $data = $writerChart->writeChart($chart); // confirm that file contains expected tags foreach ($expectedXml3D as $expected) { - self::assertSame(1, substr_count($data, $expected), $expected); + self::assertSame(0, substr_count($data, $expected), $expected); } foreach ($expectedXmlNoX as $expected) { self::assertSame(0, substr_count($data, $expected), $expected); diff --git a/tests/PhpSpreadsheetTests/Shared/DateTest.php b/tests/PhpSpreadsheetTests/Shared/DateTest.php index 6f90aed29..1f6556c57 100644 --- a/tests/PhpSpreadsheetTests/Shared/DateTest.php +++ b/tests/PhpSpreadsheetTests/Shared/DateTest.php @@ -100,6 +100,17 @@ class DateTest extends TestCase self::assertEqualsWithDelta($expectedResult, $result, 1E-5); } + #[DataProvider('providerDateTimeDateTimeToExcel')] + public function testDateTime2DateTimeToExcel(float|int $expectedResult, DateTimeInterface $dateTimeObject): void + { + // Show new parameter will override static value + Date::setExcelCalendar(Date::CALENDAR_MAC_1904); + + $result = Date::dateTimeToExcel($dateTimeObject, Date::CALENDAR_WINDOWS_1900); + self::assertEqualsWithDelta($expectedResult, $result, 1E-5); + self::assertSame(Date::CALENDAR_MAC_1904, Date::getExcelCalendar()); + } + public static function providerDateTimeDateTimeToExcel(): array { return require 'tests/data/Shared/Date/DateTimeToExcel.php'; @@ -134,6 +145,18 @@ class DateTest extends TestCase self::assertEquals($expectedResult, $result); } + #[DataProvider('providerDateTimeExcelToTimestamp1904')] + public function testDateTime2ExcelToTimestamp1904(float|int $expectedResult, float|int $excelDateTimeValue): void + { + if ($expectedResult > PHP_INT_MAX || $expectedResult < PHP_INT_MIN) { + self::markTestSkipped('Test invalid on 32-bit system.'); + } + + $result = Date::excelToTimestamp($excelDateTimeValue, calendar: Date::CALENDAR_MAC_1904); + self::assertEquals($expectedResult, $result); + self::assertSame($this->excelCalendar, Date::getExcelCalendar()); + } + public static function providerDateTimeExcelToTimestamp1904(): array { return require 'tests/data/Shared/Date/ExcelToTimestamp1904.php'; @@ -148,6 +171,14 @@ class DateTest extends TestCase self::assertEqualsWithDelta($expectedResult, $result, 1E-5); } + #[DataProvider('providerDateTimeTimestampToExcel1904')] + public function testDateTime2TimestampToExcel1904(mixed $expectedResult, float|int|string $unixTimestamp): void + { + $result = Date::timestampToExcel($unixTimestamp, Date::CALENDAR_MAC_1904); + self::assertEqualsWithDelta($expectedResult, $result, 1E-5); + self::assertSame($this->excelCalendar, Date::getExcelCalendar()); + } + public static function providerDateTimeTimestampToExcel1904(): array { return require 'tests/data/Shared/Date/TimestampToExcel1904.php'; @@ -204,6 +235,9 @@ class DateTest extends TestCase self::assertNotFalse($timestamp2); self::assertEqualsWithDelta(45803.60277777778, $timestamp1, 1.0E-10); self::assertSame($timestamp1, $timestamp2); + $timestamp3 = Date::stringToExcel('26.05.2025 14:28:00.00', Date::CALENDAR_MAC_1904); + self::assertEqualsWithDelta(45803.60277777778, 1462 + $timestamp3, 1.0E-10); + self::assertSame($this->excelCalendar, Date::getExcelCalendar()); $date = Date::PHPToExcel('2020-01-01'); self::assertEquals(43831.0, $date); From 1f4a5bdff293853becbd9881c3bebf5f662ae7a1 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:55:01 -0700 Subject: [PATCH 10/24] More Data in Parameters, Less in Static Variables --- .../Calculation/DateTimeExcel/DateValue.php | 15 +++++-- .../Calculation/DateTimeExcel/Helpers.php | 42 ++++++++++--------- src/PhpSpreadsheet/Shared/Date.php | 11 +---- 3 files changed, 35 insertions(+), 33 deletions(-) diff --git a/src/PhpSpreadsheet/Calculation/DateTimeExcel/DateValue.php b/src/PhpSpreadsheet/Calculation/DateTimeExcel/DateValue.php index db757e577..74f189e0f 100644 --- a/src/PhpSpreadsheet/Calculation/DateTimeExcel/DateValue.php +++ b/src/PhpSpreadsheet/Calculation/DateTimeExcel/DateValue.php @@ -46,13 +46,22 @@ class DateValue return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $dateValue); } + return self::fromString2($dateValue, null); + } + + /** + * @return array|DateTime|float|int|string Excel date/time serial value, PHP date/time serial value or PHP date/time object, + * depending on the value of the ReturnDateType flag + */ + public static function fromString2(null|string|int|bool|float $dateValue, ?int $calendar = null): array|string|float|int|DateTime + { // try to parse as date iff there is at least one digit if (is_string($dateValue) && preg_match('/\d/', $dateValue) !== 1) { return ExcelError::VALUE(); } $dti = new DateTimeImmutable(); - $baseYear = SharedDateHelper::getExcelCalendar(); + $baseYear = $calendar ?? SharedDateHelper::getExcelCalendar(); $dateValue = trim((string) $dateValue, '"'); // Strip any ordinals because they're allowed in Excel (English only) $dateValue = (string) preg_replace('/(\d)(st|nd|rd|th)([ -\/])/Ui', '$1$3', $dateValue); @@ -160,9 +169,9 @@ class DateValue $day = self::getInt($PHPDateArray, 'day'); $year = self::getInt($PHPDateArray, 'year'); if (!checkdate($month, $day, $year)) { - return ($year === 1900 && $month === 2 && $day === 29) ? Helpers::returnIn3FormatsFloat(60.0) : ExcelError::VALUE(); + return ($year === 1900 && $month === 2 && $day === 29) ? Helpers::returnIn3FormatsFloat(60.0, calendar: $baseYear) : ExcelError::VALUE(); } - $retValue = Helpers::returnIn3FormatsArray($PHPDateArray, true); + $retValue = Helpers::returnIn3FormatsArray($PHPDateArray, true, calendar: $baseYear); } return $retValue; diff --git a/src/PhpSpreadsheet/Calculation/DateTimeExcel/Helpers.php b/src/PhpSpreadsheet/Calculation/DateTimeExcel/Helpers.php index 492447dd0..5fc951c8b 100644 --- a/src/PhpSpreadsheet/Calculation/DateTimeExcel/Helpers.php +++ b/src/PhpSpreadsheet/Calculation/DateTimeExcel/Helpers.php @@ -30,10 +30,10 @@ class Helpers * * @return float Excel date/time serial value */ - public static function getDateValue(mixed $dateValue, bool $allowBool = true): float + public static function getDateValue(mixed $dateValue, bool $allowBool = true, ?int $calendar = null): float { if (is_object($dateValue)) { - $retval = SharedDateHelper::PHPToExcel($dateValue); + $retval = SharedDateHelper::PHPToExcel($dateValue, calendar: $calendar); if (is_bool($retval)) { throw new Exception(ExcelError::VALUE()); } @@ -41,7 +41,7 @@ class Helpers return $retval; } - self::nullFalseTrueToNumber($dateValue, $allowBool); + self::nullFalseTrueToNumber($dateValue, $allowBool, $calendar); if (!is_numeric($dateValue)) { $saveReturnDateType = Functions::getReturnDateType(); Functions::setReturnDateType(Functions::RETURNDATE_EXCEL); @@ -58,7 +58,7 @@ class Helpers } try { - SharedDateHelper::excelToDateTimeObject((float) $dateValue); + SharedDateHelper::excelToDateTimeObject((float) $dateValue, calendar: $calendar); } catch (Throwable) { throw new Exception(ExcelError::NAN()); } @@ -87,10 +87,10 @@ class Helpers * * @param float|int $dateValue date to be adjusted */ - public static function adjustDateByMonths($dateValue = 0, float $adjustmentMonths = 0): DateTime + public static function adjustDateByMonths($dateValue = 0, float $adjustmentMonths = 0, ?int $calendar = null): DateTime { // Execute function - $PHPDateObject = SharedDateHelper::excelToDateTimeObject($dateValue); + $PHPDateObject = SharedDateHelper::excelToDateTimeObject($dateValue, calendar: $calendar); $oMonth = (int) $PHPDateObject->format('m'); $oYear = (int) $PHPDateObject->format('Y'); @@ -141,7 +141,7 @@ class Helpers * * @param array{year: int, month: int, day: int, hour: int, minute: int, second: int} $dateArray */ - public static function returnIn3FormatsArray(array $dateArray, bool $noFrac = false): DateTime|float|int + public static function returnIn3FormatsArray(array $dateArray, bool $noFrac = false, ?int $calendar = null): DateTime|float|int { $retType = Functions::getReturnDateType(); if ($retType === Functions::RETURNDATE_PHP_DATETIME_OBJECT) { @@ -161,58 +161,60 @@ class Helpers $dateArray['day'], $dateArray['hour'], $dateArray['minute'], - $dateArray['second'] + $dateArray['second'], + calendar: $calendar ); if ($retType === Functions::RETURNDATE_EXCEL) { return $noFrac ? floor($excelDateValue) : $excelDateValue; } // RETURNDATE_UNIX_TIMESTAMP) - return SharedDateHelper::excelToTimestamp($excelDateValue); + return SharedDateHelper::excelToTimestamp($excelDateValue, calendar: $calendar); } /** * Return result in one of three formats. */ - public static function returnIn3FormatsFloat(float $excelDateValue): float|int|DateTime + public static function returnIn3FormatsFloat(float $excelDateValue, ?int $calendar = null): float|int|DateTime { $retType = Functions::getReturnDateType(); if ($retType === Functions::RETURNDATE_EXCEL) { return $excelDateValue; } if ($retType === Functions::RETURNDATE_UNIX_TIMESTAMP) { - return SharedDateHelper::excelToTimestamp($excelDateValue); + return SharedDateHelper::excelToTimestamp($excelDateValue, calendar: $calendar); } // RETURNDATE_PHP_DATETIME_OBJECT - return SharedDateHelper::excelToDateTimeObject($excelDateValue); + return SharedDateHelper::excelToDateTimeObject($excelDateValue, calendar: $calendar); } /** * Return result in one of three formats. */ - public static function returnIn3FormatsObject(DateTime $PHPDateObject): DateTime|float|int + public static function returnIn3FormatsObject(DateTime $PHPDateObject, ?int $calendar = null): DateTime|float|int { $retType = Functions::getReturnDateType(); if ($retType === Functions::RETURNDATE_PHP_DATETIME_OBJECT) { return $PHPDateObject; } if ($retType === Functions::RETURNDATE_EXCEL) { - return (float) SharedDateHelper::PHPToExcel($PHPDateObject); + return (float) SharedDateHelper::PHPToExcel($PHPDateObject, calendar: $calendar); } // RETURNDATE_UNIX_TIMESTAMP - $stamp = SharedDateHelper::PHPToExcel($PHPDateObject); + $stamp = SharedDateHelper::PHPToExcel($PHPDateObject, calendar: $calendar); $stamp = is_bool($stamp) ? ((int) $stamp) : $stamp; - return SharedDateHelper::excelToTimestamp($stamp); + return SharedDateHelper::excelToTimestamp($stamp, calendar: $calendar); } - private static function baseDate(): int + private static function baseDate(?int $calendar): int { if (Functions::getCompatibilityMode() === Functions::COMPATIBILITY_OPENOFFICE) { return 0; } - if (SharedDateHelper::getExcelCalendar() === SharedDateHelper::CALENDAR_MAC_1904) { + $calendar ??= SharedDateHelper::getExcelCalendar(); + if ($calendar === SharedDateHelper::CALENDAR_MAC_1904) { return 0; } @@ -222,10 +224,10 @@ class Helpers /** * Many functions accept null/false/true argument treated as 0/0/1. */ - public static function nullFalseTrueToNumber(mixed &$number, bool $allowBool = true): void + public static function nullFalseTrueToNumber(mixed &$number, bool $allowBool = true, ?int $calendar = null): void { $number = Functions::flattenSingleValue($number); - $nullVal = self::baseDate(); + $nullVal = self::baseDate($calendar); if ($number === null) { $number = $nullVal; } elseif ($allowBool && is_bool($number)) { diff --git a/src/PhpSpreadsheet/Shared/Date.php b/src/PhpSpreadsheet/Shared/Date.php index 3746cd46b..fbb48c927 100644 --- a/src/PhpSpreadsheet/Shared/Date.php +++ b/src/PhpSpreadsheet/Shared/Date.php @@ -488,16 +488,7 @@ class Date return false; } - $hold = self::$excelCalendar; - - try { - if ($calendar !== null) { - self::$excelCalendar = $calendar; - } - $dateValueNew = DateTimeExcel\DateValue::fromString($dateValue); - } finally { - self::$excelCalendar = $hold; - } + $dateValueNew = DateTimeExcel\DateValue::fromString2($dateValue, $calendar); if (!is_float($dateValueNew)) { return false; From 94abde43f8eec20ec5eee6a44d423cbc65bde955 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:07:20 -0700 Subject: [PATCH 11/24] Slightly More Coverage --- src/PhpSpreadsheet/Chart/Layout.php | 6 +--- .../PhpSpreadsheetTests/Chart/RenderTest.php | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/PhpSpreadsheet/Chart/Layout.php b/src/PhpSpreadsheet/Chart/Layout.php index 6b90b7b58..dbf9f75fe 100644 --- a/src/PhpSpreadsheet/Chart/Layout.php +++ b/src/PhpSpreadsheet/Chart/Layout.php @@ -508,11 +508,7 @@ class Layout public function getLabelFontColor(): ?ChartColor { - if ($this->labelFont === null) { - return null; - } - - return $this->labelFont->getChartColor(); + return $this->labelFont?->getChartColor(); } public function setLabelFontColor(?ChartColor $chartColor): self diff --git a/tests/PhpSpreadsheetTests/Chart/RenderTest.php b/tests/PhpSpreadsheetTests/Chart/RenderTest.php index fe1a0c1eb..50254d9e0 100644 --- a/tests/PhpSpreadsheetTests/Chart/RenderTest.php +++ b/tests/PhpSpreadsheetTests/Chart/RenderTest.php @@ -4,14 +4,45 @@ declare(strict_types=1); namespace PhpOffice\PhpSpreadsheetTests\Chart; +use finfo; use PhpOffice\PhpSpreadsheet\Chart\Chart; +use PhpOffice\PhpSpreadsheet\Chart\Renderer\MtJpGraphRenderer; +use PhpOffice\PhpSpreadsheet\Reader\Xlsx as XlsxReader; +use PhpOffice\PhpSpreadsheet\Settings; use PHPUnit\Framework\TestCase; class RenderTest extends TestCase { + protected function tearDown(): void + { + Settings::unsetChartRenderer(); + } + public function testNoRenderer(): void { $chart = new Chart('Chart1'); self::assertFalse($chart->render()); } + + public function testPhpOutput(): void + { + $infile = 'samples/templates/32readwriteAreaChart1.xlsx'; + $reader = new XlsxReader(); + $reader->setIncludeCharts(true); + $spreadsheet = $reader->load($infile); + $sheet = $spreadsheet->getActiveSheet(); + $charts = $sheet->getChartCollection(); + self::assertCount(1, $charts); + $chart = $charts[0]; + self::assertInstanceOf(Chart::class, $chart); + Settings::setChartRenderer(MtJpGraphRenderer::class); + ob_start(); + $chart->render('php://output'); + $data = ob_get_clean(); + self::assertNotFalse($data); + $finfo = new finfo(FILEINFO_MIME_TYPE); + $type = $finfo->buffer($data); + self::assertSame('image/png', $type); + $spreadsheet->disconnectWorksheets(); + } } From e623d5411b6b95f750702824f6115fc0bb858fb0 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:56:44 -0700 Subject: [PATCH 12/24] WIP Why Is This File Needed? --- .../Reader/Utility/File.php | 201 ------------------ .../Reader/Xlsx/URLImageTest.php | 1 - 2 files changed, 202 deletions(-) delete mode 100644 tests/PhpSpreadsheetTests/Reader/Utility/File.php diff --git a/tests/PhpSpreadsheetTests/Reader/Utility/File.php b/tests/PhpSpreadsheetTests/Reader/Utility/File.php deleted file mode 100644 index 0290617b5..000000000 --- a/tests/PhpSpreadsheetTests/Reader/Utility/File.php +++ /dev/null @@ -1,201 +0,0 @@ - '3g2', - 'video/3gp' => '3gp', - 'video/3gpp' => '3gp', - 'application/x-compressed' => '7zip', - 'audio/x-acc' => 'aac', - 'audio/ac3' => 'ac3', - 'application/postscript' => 'ai', - 'audio/x-aiff' => 'aif', - 'audio/aiff' => 'aif', - 'audio/x-au' => 'au', - 'video/x-msvideo' => 'avi', - 'video/msvideo' => 'avi', - 'video/avi' => 'avi', - 'application/x-troff-msvideo' => 'avi', - 'application/macbinary' => 'bin', - 'application/mac-binary' => 'bin', - 'application/x-binary' => 'bin', - 'application/x-macbinary' => 'bin', - 'image/bmp' => 'bmp', - 'image/x-bmp' => 'bmp', - 'image/x-bitmap' => 'bmp', - 'image/x-xbitmap' => 'bmp', - 'image/x-win-bitmap' => 'bmp', - 'image/x-windows-bmp' => 'bmp', - 'image/ms-bmp' => 'bmp', - 'image/x-ms-bmp' => 'bmp', - 'application/bmp' => 'bmp', - 'application/x-bmp' => 'bmp', - 'application/x-win-bitmap' => 'bmp', - 'application/cdr' => 'cdr', - 'application/coreldraw' => 'cdr', - 'application/x-cdr' => 'cdr', - 'application/x-coreldraw' => 'cdr', - 'image/cdr' => 'cdr', - 'image/x-cdr' => 'cdr', - 'zz-application/zz-winassoc-cdr' => 'cdr', - 'application/mac-compactpro' => 'cpt', - 'application/pkix-crl' => 'crl', - 'application/pkcs-crl' => 'crl', - 'application/x-x509-ca-cert' => 'crt', - 'application/pkix-cert' => 'crt', - 'text/css' => 'css', - 'text/x-comma-separated-values' => 'csv', - 'text/comma-separated-values' => 'csv', - 'application/vnd.msexcel' => 'csv', - 'application/x-director' => 'dcr', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx', - 'application/x-dvi' => 'dvi', - 'message/rfc822' => 'eml', - 'application/x-msdownload' => 'exe', - 'video/x-f4v' => 'f4v', - 'audio/x-flac' => 'flac', - 'video/x-flv' => 'flv', - 'image/gif' => 'gif', - 'application/gpg-keys' => 'gpg', - 'application/x-gtar' => 'gtar', - 'application/x-gzip' => 'gzip', - 'application/mac-binhex40' => 'hqx', - 'application/mac-binhex' => 'hqx', - 'application/x-binhex40' => 'hqx', - 'application/x-mac-binhex40' => 'hqx', - 'text/html' => 'html', - 'image/x-icon' => 'ico', - 'image/x-ico' => 'ico', - 'image/vnd.microsoft.icon' => 'ico', - 'text/calendar' => 'ics', - 'application/java-archive' => 'jar', - 'application/x-java-application' => 'jar', - 'application/x-jar' => 'jar', - 'image/jp2' => 'jp2', - 'video/mj2' => 'jp2', - 'image/jpx' => 'jp2', - 'image/jpm' => 'jp2', - 'image/jpeg' => 'jpeg', - 'image/pjpeg' => 'jpeg', - 'application/x-javascript' => 'js', - 'application/json' => 'json', - 'text/json' => 'json', - 'application/vnd.google-earth.kml+xml' => 'kml', - 'application/vnd.google-earth.kmz' => 'kmz', - 'text/x-log' => 'log', - 'audio/x-m4a' => 'm4a', - 'audio/mp4' => 'm4a', - 'application/vnd.mpegurl' => 'm4u', - 'audio/midi' => 'mid', - 'application/vnd.mif' => 'mif', - 'video/quicktime' => 'mov', - 'video/x-sgi-movie' => 'movie', - 'audio/mpeg' => 'mp3', - 'audio/mpg' => 'mp3', - 'audio/mpeg3' => 'mp3', - 'audio/mp3' => 'mp3', - 'video/mp4' => 'mp4', - 'video/mpeg' => 'mpeg', - 'application/oda' => 'oda', - 'audio/ogg' => 'ogg', - 'video/ogg' => 'ogg', - 'application/ogg' => 'ogg', - 'font/otf' => 'otf', - 'application/x-pkcs10' => 'p10', - 'application/pkcs10' => 'p10', - 'application/x-pkcs12' => 'p12', - 'application/x-pkcs7-signature' => 'p7a', - 'application/pkcs7-mime' => 'p7c', - 'application/x-pkcs7-mime' => 'p7c', - 'application/x-pkcs7-certreqresp' => 'p7r', - 'application/pkcs7-signature' => 'p7s', - 'application/pdf' => 'pdf', - 'application/octet-stream' => 'pdf', - 'application/x-x509-user-cert' => 'pem', - 'application/x-pem-file' => 'pem', - 'application/pgp' => 'pgp', - 'application/x-httpd-php' => 'php', - 'application/php' => 'php', - 'application/x-php' => 'php', - 'text/php' => 'php', - 'text/x-php' => 'php', - 'application/x-httpd-php-source' => 'php', - 'image/png' => 'png', - 'image/x-png' => 'png', - 'application/powerpoint' => 'ppt', - 'application/vnd.ms-powerpoint' => 'ppt', - 'application/vnd.ms-office' => 'ppt', - 'application/msword' => 'doc', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx', - 'application/x-photoshop' => 'psd', - 'image/vnd.adobe.photoshop' => 'psd', - 'audio/x-realaudio' => 'ra', - 'audio/x-pn-realaudio' => 'ram', - 'application/x-rar' => 'rar', - 'application/rar' => 'rar', - 'application/x-rar-compressed' => 'rar', - 'audio/x-pn-realaudio-plugin' => 'rpm', - 'application/x-pkcs7' => 'rsa', - 'text/rtf' => 'rtf', - 'text/richtext' => 'rtx', - 'video/vnd.rn-realvideo' => 'rv', - 'application/x-stuffit' => 'sit', - 'application/smil' => 'smil', - 'text/srt' => 'srt', - 'image/svg+xml' => 'svg', - 'application/x-shockwave-flash' => 'swf', - 'application/x-tar' => 'tar', - 'application/x-gzip-compressed' => 'tgz', - 'image/tiff' => 'tiff', - 'font/ttf' => 'ttf', - 'text/plain' => 'txt', - 'text/x-vcard' => 'vcf', - 'application/videolan' => 'vlc', - 'text/vtt' => 'vtt', - 'audio/x-wav' => 'wav', - 'audio/wave' => 'wav', - 'audio/wav' => 'wav', - 'application/wbxml' => 'wbxml', - 'video/webm' => 'webm', - 'image/webp' => 'webp', - 'audio/x-ms-wma' => 'wma', - 'application/wmlc' => 'wmlc', - 'video/x-ms-wmv' => 'wmv', - 'video/x-ms-asf' => 'wmv', - 'font/woff' => 'woff', - 'font/woff2' => 'woff2', - 'application/xhtml+xml' => 'xhtml', - 'application/excel' => 'xl', - 'application/msexcel' => 'xls', - 'application/x-msexcel' => 'xls', - 'application/x-ms-excel' => 'xls', - 'application/x-excel' => 'xls', - 'application/x-dos_ms_excel' => 'xls', - 'application/xls' => 'xls', - 'application/x-xls' => 'xls', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx', - 'application/vnd.ms-excel' => 'xlsx', - 'application/xml' => 'xml', - 'text/xml' => 'xml', - 'text/xsl' => 'xsl', - 'application/xspf+xml' => 'xspf', - 'application/x-compress' => 'z', - 'application/x-zip' => 'zip', - 'application/zip' => 'zip', - 'application/x-zip-compressed' => 'zip', - 'application/s-compressed' => 'zip', - 'multipart/x-zip' => 'zip', - 'text/x-scriptzsh' => 'zsh', - ]; - - return $mime_map[$mime] ?? ''; - } -} diff --git a/tests/PhpSpreadsheetTests/Reader/Xlsx/URLImageTest.php b/tests/PhpSpreadsheetTests/Reader/Xlsx/URLImageTest.php index 69e0eee24..ce371deed 100644 --- a/tests/PhpSpreadsheetTests/Reader/Xlsx/URLImageTest.php +++ b/tests/PhpSpreadsheetTests/Reader/Xlsx/URLImageTest.php @@ -8,7 +8,6 @@ use PhpOffice\PhpSpreadsheet\Exception as SpreadsheetException; use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\Reader\Xlsx as XlsxReader; use PhpOffice\PhpSpreadsheet\Worksheet\Drawing; -use PhpOffice\PhpSpreadsheetTests\Reader\Utility\File; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; From b65aecdcec73092b9ac09740b175188b475c711a Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:04:39 -0700 Subject: [PATCH 13/24] Readers Should Directly Access readFilter A number of interesting performance ideas have been submitted by @kemo. PR #4833 optimizes Xls Reader in a number of different ways. One of those, slightly modified, is to use the protected `readFilter` property which is available to all classes which inherit from `BaseReader` (which all our supported readers do), rather than call the `getReadFilter` method to obtain it (often in a loop). That applies to all readers, not just Xls, and is such a good idea that I am fast-tracking it ahead of 4833. Gnumeric, Ods, Xls, Xlsx, and Xml will all receive small performance boosts from it. Csv already does this, so no change is needed there. Html and Slk do not appear to do any filtering; possibly work for another day. --- src/PhpSpreadsheet/Reader/Gnumeric.php | 2 +- src/PhpSpreadsheet/Reader/Ods.php | 6 +++--- src/PhpSpreadsheet/Reader/Xls.php | 20 +++++++++---------- .../Reader/Xls/LoadSpreadsheet.php | 2 +- src/PhpSpreadsheet/Reader/Xlsx.php | 4 ++-- src/PhpSpreadsheet/Reader/Xml.php | 2 +- .../Reader/Xlsx/Issue4416Test.php | 3 +++ 7 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/PhpSpreadsheet/Reader/Gnumeric.php b/src/PhpSpreadsheet/Reader/Gnumeric.php index 85f8404bd..0e881b28d 100644 --- a/src/PhpSpreadsheet/Reader/Gnumeric.php +++ b/src/PhpSpreadsheet/Reader/Gnumeric.php @@ -311,7 +311,7 @@ class Gnumeric extends BaseReader $column = Coordinate::stringFromColumnIndex($column + 1); // Read cell? - if (!$this->getReadFilter()->readCell($column, $row, $worksheetName)) { + if (!$this->readFilter->readCell($column, $row, $worksheetName)) { continue; } diff --git a/src/PhpSpreadsheet/Reader/Ods.php b/src/PhpSpreadsheet/Reader/Ods.php index 818753a79..4ddbae090 100644 --- a/src/PhpSpreadsheet/Reader/Ods.php +++ b/src/PhpSpreadsheet/Reader/Ods.php @@ -851,7 +851,7 @@ class Ods extends BaseReader // repeated range passes the read filter. If not, skip the entire group. // If some columns pass, we need to fall through to the processing block // which will handle per-column filtering. - if (!$this->getReadFilter()->readCell($columnID, $rowID, $worksheetName)) { + if (!$this->readFilter->readCell($columnID, $rowID, $worksheetName)) { if ($colRepeats <= 1) { StringHelper::stringIncrement($columnID); @@ -865,7 +865,7 @@ class Ods extends BaseReader if ($i > 0) { StringHelper::stringIncrement($tempCol); } - if ($this->getReadFilter()->readCell($tempCol, $rowID, $worksheetName)) { + if ($this->readFilter->readCell($tempCol, $rowID, $worksheetName)) { $anyColumnPasses = true; break; @@ -1143,7 +1143,7 @@ class Ods extends BaseReader StringHelper::stringIncrement($columnID); } - if (!$this->getReadFilter()->readCell($columnID, $rowID, $worksheetName)) { + if (!$this->readFilter->readCell($columnID, $rowID, $worksheetName)) { continue; } diff --git a/src/PhpSpreadsheet/Reader/Xls.php b/src/PhpSpreadsheet/Reader/Xls.php index b7d34bc28..a79c66269 100644 --- a/src/PhpSpreadsheet/Reader/Xls.php +++ b/src/PhpSpreadsheet/Reader/Xls.php @@ -2728,7 +2728,7 @@ class Xls extends XlsBase $columnString = Coordinate::stringFromColumnIndex($column + 1); // Read cell? - if ($this->getReadFilter()->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { + if ($this->readFilter->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { // offset: 4; size: 2; index to XF record $xfIndex = self::getUInt2d($recordData, 4); @@ -2773,7 +2773,7 @@ class Xls extends XlsBase $cell = null; // Read cell? - if ($this->getReadFilter()->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { + if ($this->readFilter->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { // offset: 4; size: 2; index to XF record $xfIndex = self::getUInt2d($recordData, 4); @@ -2874,7 +2874,7 @@ class Xls extends XlsBase $columnString = Coordinate::stringFromColumnIndex($colFirst + $i); // Read cell? - if ($this->getReadFilter()->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { + if ($this->readFilter->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { // offset: var; size: 2; index to XF record $xfIndex = self::getUInt2d($recordData, $offset); @@ -2918,7 +2918,7 @@ class Xls extends XlsBase $columnString = Coordinate::stringFromColumnIndex($column + 1); // Read cell? - if ($this->getReadFilter()->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { + if ($this->readFilter->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { // offset 4; size: 2; index to XF record $xfIndex = self::getUInt2d($recordData, 4); @@ -2984,7 +2984,7 @@ class Xls extends XlsBase } // Read cell? - if ($this->getReadFilter()->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { + if ($this->readFilter->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { if ($isPartOfSharedFormula) { // formula is added to this cell after the sheet has been read $this->sharedFormulaParts[$columnString . ($row + 1)] = $this->baseCell; @@ -3150,7 +3150,7 @@ class Xls extends XlsBase $columnString = Coordinate::stringFromColumnIndex($column + 1); // Read cell? - if ($this->getReadFilter()->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { + if ($this->readFilter->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { // offset: 4; size: 2; index to XF record $xfIndex = self::getUInt2d($recordData, 4); @@ -3214,7 +3214,7 @@ class Xls extends XlsBase $columnString = Coordinate::stringFromColumnIndex($fc + $i + 1); // Read cell? - if ($this->getReadFilter()->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { + if ($this->readFilter->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { $xfIndex = self::getUInt2d($recordData, 4 + 2 * $i); if (isset($this->mapCellXfIndex[$xfIndex])) { $this->phpSheet->getCell($columnString . ($row + 1))->setXfIndex($this->mapCellXfIndex[$xfIndex]); @@ -3252,7 +3252,7 @@ class Xls extends XlsBase $columnString = Coordinate::stringFromColumnIndex($column + 1); // Read cell? - if ($this->getReadFilter()->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { + if ($this->readFilter->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { // offset: 4; size: 2; XF index $xfIndex = self::getUInt2d($recordData, 4); @@ -3297,7 +3297,7 @@ class Xls extends XlsBase $columnString = Coordinate::stringFromColumnIndex($col + 1); // Read cell? - if ($this->getReadFilter()->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { + if ($this->readFilter->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { // offset: 4; size: 2; XF index $xfIndex = self::getUInt2d($recordData, 4); @@ -3605,7 +3605,7 @@ class Xls extends XlsBase StringHelper::stringIncrement($rangeBoundaries[1][0]); for ($row = $rangeBoundaries[0][1]; $row <= $rangeBoundaries[1][1]; ++$row) { for ($column = $rangeBoundaries[0][0]; $column != $rangeBoundaries[1][0]; StringHelper::stringIncrement($column)) { - if ($this->getReadFilter()->readCell($column, $row, $this->phpSheet->getTitle())) { + if ($this->readFilter->readCell($column, $row, $this->phpSheet->getTitle())) { $includeCellRange = true; break 2; diff --git a/src/PhpSpreadsheet/Reader/Xls/LoadSpreadsheet.php b/src/PhpSpreadsheet/Reader/Xls/LoadSpreadsheet.php index 402b9f59d..ebe9d515f 100644 --- a/src/PhpSpreadsheet/Reader/Xls/LoadSpreadsheet.php +++ b/src/PhpSpreadsheet/Reader/Xls/LoadSpreadsheet.php @@ -551,7 +551,7 @@ class LoadSpreadsheet extends Xls /** @var int $row */ [$column, $row] = Coordinate::coordinateFromString($cell); /** @var string $baseCell */ - if ($xls->getReadFilter()->readCell($column, $row, $xls->phpSheet->getTitle())) { + if ($xls->readFilter->readCell($column, $row, $xls->phpSheet->getTitle())) { /** @var string */ $temp = $xls->sharedFormulas[$baseCell]; $formula = $xls->getFormulaFromStructure($temp, $cell); diff --git a/src/PhpSpreadsheet/Reader/Xlsx.php b/src/PhpSpreadsheet/Reader/Xlsx.php index 4aa55b999..a65628fcb 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx.php +++ b/src/PhpSpreadsheet/Reader/Xlsx.php @@ -890,7 +890,7 @@ class Xlsx extends BaseReader $sheetViewOptions->load($this->readDataOnly, $this->styleReader); (new ColumnAndRowAttributes($docSheet, $xmlSheetNS)) - ->load($this->getReadFilter(), $this->readDataOnly, $this->ignoreRowsWithNoCells); + ->load($this->readFilter, $this->readDataOnly, $this->ignoreRowsWithNoCells); } $holdSelectedCells = $docSheet->getSelectedCells(); @@ -1931,7 +1931,7 @@ class Xlsx extends BaseReader // Read cell? $coordinates = Coordinate::coordinateFromString($r); - if (!$this->getReadFilter()->readCell($coordinates[0], (int) $coordinates[1], $docSheet->getTitle())) { + if (!$this->readFilter->readCell($coordinates[0], (int) $coordinates[1], $docSheet->getTitle())) { // Normally, just testing for the f attribute should identify this cell as containing a formula // that we need to read, even though it is outside of the filter range, in case it is a shared formula. // But in some cases, this attribute isn't set; so we need to delve a level deeper and look at diff --git a/src/PhpSpreadsheet/Reader/Xml.php b/src/PhpSpreadsheet/Reader/Xml.php index 4fba7dcd7..f5cdc8108 100644 --- a/src/PhpSpreadsheet/Reader/Xml.php +++ b/src/PhpSpreadsheet/Reader/Xml.php @@ -432,7 +432,7 @@ class Xml extends BaseReader $arrayRef = AddressHelper::convertFormulaToA1($arrayRange, $rowID, $columnIndex); } - if (!$this->getReadFilter()->readCell($columnID, $rowID, $worksheetName)) { + if (!$this->readFilter->readCell($columnID, $rowID, $worksheetName)) { continue; } diff --git a/tests/PhpSpreadsheetTests/Reader/Xlsx/Issue4416Test.php b/tests/PhpSpreadsheetTests/Reader/Xlsx/Issue4416Test.php index 8a139b03c..2e5ae320a 100644 --- a/tests/PhpSpreadsheetTests/Reader/Xlsx/Issue4416Test.php +++ b/tests/PhpSpreadsheetTests/Reader/Xlsx/Issue4416Test.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace PhpOffice\PhpSpreadsheetTests\Reader\Xlsx; +use PhpOffice\PhpSpreadsheet\Reader\DefaultReadFilter; use PhpOffice\PhpSpreadsheet\Reader\Xlsx as XlsxReader; use PHPUnit\Framework\TestCase; @@ -49,7 +50,9 @@ class Issue4416Test extends TestCase { $file = self::$file; $reader = new XlsxReader(); + self::assertInstanceOf(DefaultReadFilter::class, $reader->getReadFilter()); $reader->setReadFilter(new Issue4416Filter()); + self::assertInstanceOf(Issue4416Filter::class, $reader->getReadFilter()); $spreadsheet = $reader->load($file); $sheet = $spreadsheet->getActiveSheet(); self::assertEqualsWithDelta( From 46a6fc1670c6fbf45f0eed23035ba21832f22bee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:05:46 +0000 Subject: [PATCH 14/24] Bump actions/checkout from 6 to 7 Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/github-pages.yml | 2 +- .github/workflows/main.yml | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index 546deedef..23f58c2bc 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP, with composer and extensions uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 932d052ee..ea7118b7e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Install locales run: sudo apt-get update && sudo apt-get install -y language-pack-fr language-pack-de @@ -75,7 +75,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 2 @@ -94,7 +94,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP, with composer and extensions uses: shivammathur/setup-php@v2 @@ -125,7 +125,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP, with composer and extensions uses: shivammathur/setup-php@v2 @@ -156,7 +156,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP, with composer and extensions uses: shivammathur/setup-php@v2 @@ -187,7 +187,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP, with composer and extensions uses: shivammathur/setup-php@v2 @@ -218,7 +218,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -267,7 +267,7 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: ref: ${{ github.ref }} # Otherwise our annotated tag is not fetched and we cannot get correct version From 81379f430c457a00459214aa7e8b429ee94bb893 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:57:17 -0700 Subject: [PATCH 15/24] Update CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 793174876..e003679ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a - Navigate Cell as Cursor. [Issue #863](https://github.com/PHPOffice/PhpSpreadsheet/issues/863) [PR #4909](https://github.com/PHPOffice/PhpSpreadsheet/pull/4909) - Chart DataTables. [Issue #413](https://github.com/PHPOffice/PhpSpreadsheet/issues/413) [PR #4911](https://github.com/PHPOffice/PhpSpreadsheet/pull/4911) +- Permit Ignoring "Misleading Format" Tag. [PR #4914](https://github.com/PHPOffice/PhpSpreadsheet/pull/4914) ### Removed @@ -33,6 +34,8 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a - Small improvement for Radar Charts. [Issue #661](https://github.com/PHPOffice/PhpSpreadsheet/issues/661) [PR #4908](https://github.com/PHPOffice/PhpSpreadsheet/pull/4908) - Allow Use of Multiple Ranges When Setting Styles. [Issue #411](https://github.com/PHPOffice/PhpSpreadsheet/issues/411) [PR #4910](https://github.com/PHPOffice/PhpSpreadsheet/pull/4910) - Small change to Writer Html. [Issue #434](https://github.com/PHPOffice/PhpSpreadsheet/issues/434) [PR #4912](https://github.com/PHPOffice/PhpSpreadsheet/pull/4912) +- Avoid hard-coding some chart Xml attributes. [PR #4915](https://github.com/PHPOffice/PhpSpreadsheet/pull/4915) +- Readers should directly access readFilter. [PR #4919](https://github.com/PHPOffice/PhpSpreadsheet/pull/4919) ## 2026-06-06 - 5.8.0 From 5d5f140f2fee7c90b598b1b96a7da8218cc28af3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:05:27 +0000 Subject: [PATCH 16/24] Bump actions/cache from 5 to 6 Bumps [actions/cache](https://github.com/actions/cache) from 5 to 6. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/main.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ea7118b7e..2473e75a7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -44,7 +44,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} @@ -109,7 +109,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} @@ -140,7 +140,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} @@ -171,7 +171,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} @@ -202,7 +202,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} @@ -240,7 +240,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} From 8fd7c56ef03664dd9d515f65776da21e3e9cb6e5 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:41:09 -0700 Subject: [PATCH 17/24] Consistent HighestRow/Column After Row/Column Delete Fix #943, which went stale a long time ago, and is now reopened. I believe that the specific problem in that issue was actually mostly resolved some time ago. However, `highestRow/Column` produces questionable results when the delete range overlaps the highest row/column. That is fixed by this PR. In addition, `removeRow/Column` allow the specification of a non-negative value for `numberOfRows/Columns`. But the expected results in those cases are not defined, and the actual results probably do not meet user expectations. I believe that very few, if any, users are taking advantage of this "feature", but we may as well straighten it out. For 0 rows/columns, I think it makes perfect sense to do nothing. As for negative - if `removeRow(5, 2)` says to remove 2 rows *starting* with 5, I think `removeRow(5, -2)` ought to remove (up to) 2 rows *ending* with 5. The undefined behaviors are changed to act as described in this paragraph. --- src/PhpSpreadsheet/Worksheet/Worksheet.php | 29 +++++++++ .../Worksheet/InsertTest.php | 44 +++++++++++--- .../Worksheet/RemoveTest.php | 59 +++++++++++++++++++ 3 files changed, 123 insertions(+), 9 deletions(-) diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index 5c26e2dd8..e8223a173 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -2443,6 +2443,18 @@ class Worksheet if ($row < 1) { throw new Exception('Rows to be deleted should at least start from row 1.'); } + if ($numberOfRows === 0) { + return $this; + } + if ($numberOfRows < 0) { + $newRow = max(1, $row + $numberOfRows + 1); + $numberOfRows = $row - $newRow + 1; + $row = $newRow; + } + $newHighestRow = $this->cachedHighestRow; + if ($newHighestRow >= $row) { + $newHighestRow = max($row - 1, $this->cachedHighestRow - $numberOfRows); + } $startRow = $row; $endRow = $startRow + $numberOfRows - 1; $removeKeys = []; @@ -2499,6 +2511,7 @@ class Worksheet } $this->rowDimensions = $holdRowDimensions; + $this->cachedHighestRow = $newHighestRow; return $this; } @@ -2537,6 +2550,19 @@ class Worksheet throw new Exception('Column references should not be numeric.'); } $startColumnInt = Coordinate::columnIndexFromString($column); + if ($numberOfColumns === 0) { + return $this; + } + if ($numberOfColumns < 0) { + $newStartColumnInt = max(1, $startColumnInt + $numberOfColumns + 1); + $numberOfColumns = $startColumnInt - $newStartColumnInt + 1; + $startColumnInt = $newStartColumnInt; + $column = Coordinate::stringFromColumnIndex($startColumnInt); + } + $newHighestColumn = $this->cachedHighestColumn; + if ($newHighestColumn >= $startColumnInt) { + $newHighestColumn = max($startColumnInt - 1, $this->cachedHighestColumn - $numberOfColumns); + } $endColumnInt = $startColumnInt + $numberOfColumns - 1; $removeKeys = []; $addKeys = []; @@ -2587,6 +2613,8 @@ class Worksheet $this->columnDimensions = $holdColumnDimensions; if ($pColumnIndex > $highestColumnIndex) { + $this->cachedHighestColumn = $newHighestColumn; + return $this; } @@ -2596,6 +2624,7 @@ class Worksheet $this->cellCollection->removeColumn($highestColumn); $highestColumn = Coordinate::stringFromColumnIndex(Coordinate::columnIndexFromString($highestColumn) - 1); } + $this->cachedHighestColumn = $newHighestColumn; $this->garbageCollect(); diff --git a/tests/PhpSpreadsheetTests/Worksheet/InsertTest.php b/tests/PhpSpreadsheetTests/Worksheet/InsertTest.php index 33eb86ec8..2392e2700 100644 --- a/tests/PhpSpreadsheetTests/Worksheet/InsertTest.php +++ b/tests/PhpSpreadsheetTests/Worksheet/InsertTest.php @@ -28,12 +28,24 @@ class InsertTest extends TestCase $sheet->insertNewRowBefore($currentRow, 1); self::assertSame(1001, $sheet->getHighestRow()); self::assertSame(6, $sheet->getHighestDataRow()); - self::assertTrue($sheet->getStyle('C3')->getFont()->getBold()); + self::assertTrue( + $sheet->getStyle('C3')->getFont()->getBold() + ); self::assertSame(11, $sheet->getCell('C3')->getValue()); - self::assertTrue($sheet->getStyle('C4')->getFont()->getBold()); + self::assertTrue( + $sheet->getStyle('C4')->getFont()->getBold() + ); self::assertNull($sheet->getCell('C4')->getValue()); - self::assertFalse($sheet->getRowDimension(1001)->getVisible()); - self::assertTrue($sheet->getRowDimension(1000)->getVisible()); + self::assertFalse( + $sheet->getRowDimension(1001)->getVisible() + ); + self::assertTrue( + $sheet->getRowDimension(1000)->getVisible() + ); + $sheet->removeRow(15, 10); + self::assertSame(991, $sheet->getHighestRow(), 'highest row decreases by 10'); + $sheet->removeRow(985, 10); + self::assertSame(984, $sheet->getHighestRow(), 'delete range overlaps highest row so highest is now row before delete'); $spreadsheet->disconnectWorksheets(); } @@ -56,12 +68,24 @@ class InsertTest extends TestCase $sheet->insertNewColumnBefore($currentColumn, 1); self::assertSame('ZZ', $sheet->getHighestColumn()); self::assertSame('E', $sheet->getHighestDataColumn()); - self::assertTrue($sheet->getStyle('C3')->getFont()->getBold()); + self::assertTrue( + $sheet->getStyle('C3')->getFont()->getBold() + ); self::assertSame(11, $sheet->getCell('C3')->getValue()); - self::assertTrue($sheet->getStyle('D3')->getFont()->getBold()); + self::assertTrue( + $sheet->getStyle('D3')->getFont()->getBold() + ); self::assertNull($sheet->getCell('D3')->getValue()); - self::assertFalse($sheet->getColumnDimension('ZZ')->getVisible()); - self::assertTrue($sheet->getColumnDimension('ZY')->getVisible()); + self::assertFalse( + $sheet->getColumnDimension('ZZ')->getVisible() + ); + self::assertTrue( + $sheet->getColumnDimension('ZY')->getVisible() + ); + $sheet->removeColumn('G', 5); + self::assertSame('ZU', $sheet->getHighestColumn(), 'ZZ moved over 5 columns'); + $sheet->removeColumn('ZR', 5); + self::assertSame('ZQ', $sheet->getHighestColumn(), 'delete range overlaps highest column so new highest is one before deleted columns'); $spreadsheet->disconnectWorksheets(); } @@ -97,7 +121,9 @@ class InsertTest extends TestCase ]); $sheet->getCell('XFD1')->setValue('lastcol'); $sheet->insertNewColumnBefore('D', 4); - self::assertFalse($sheet->getCellCollection()->has('XFH1')); + self::assertFalse( + $sheet->getCellCollection()->has('XFH1') + ); $spreadsheet->disconnectWorksheets(); } } diff --git a/tests/PhpSpreadsheetTests/Worksheet/RemoveTest.php b/tests/PhpSpreadsheetTests/Worksheet/RemoveTest.php index 9778aa9b0..e33a8802c 100644 --- a/tests/PhpSpreadsheetTests/Worksheet/RemoveTest.php +++ b/tests/PhpSpreadsheetTests/Worksheet/RemoveTest.php @@ -8,6 +8,7 @@ use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Style\Color; use PhpOffice\PhpSpreadsheet\Style\Fill; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; class RemoveTest extends TestCase @@ -86,4 +87,62 @@ class RemoveTest extends TestCase $spreadsheet->disconnectWorksheets(); } + + /** + * @param array> $expectedArray + */ + #[DataProvider('providerColumnEdgeCases')] + public function testColumnEdgeCases(string $start, int $num, array $expectedArray, string $expectedHighestColumn): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->fromArray([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + $sheet->removeColumn($start, $num); + self::assertSame($expectedArray, $sheet->toArray(formatData: false)); + self::assertSame($expectedHighestColumn, $sheet->getHighestColumn()); + + $spreadsheet->disconnectWorksheets(); + } + + /** + * @return array + */ + public static function providerColumnEdgeCases(): array + { + return [ + 'remove positive cols' => ['E', 2, [[1, 2, 3, 4, 7, 8, 9, 10]], 'H'], + 'remove negative cols' => ['E', -2, [[1, 2, 3, 6, 7, 8, 9, 10]], 'H'], + 'remove zero cols' => ['E', 0, [[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]], 'J'], + 'remove cols above highest' => ['T', 2, [[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]], 'J'], + ]; + } + + /** + * @param array> $expectedArray + */ + #[DataProvider('providerRowEdgeCases')] + public function testRowEdgeCases(int $start, int $num, array $expectedArray, int $expectedHighestRow): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->fromArray([[1], [2], [3], [4], [5], [6], [7], [8], [9], [10]]); + $sheet->removeRow($start, $num); + self::assertSame($expectedArray, $sheet->toArray(formatData: false)); + self::assertSame($expectedHighestRow, $sheet->getHighestRow()); + + $spreadsheet->disconnectWorksheets(); + } + + /** + * @return array>, int}> + */ + public static function providerRowEdgeCases(): array + { + return [ + 'remove positive rows' => [5, 2, [[1], [2], [3], [4], [7], [8], [9], [10]], 8], + 'remove negative rows' => [5, -2, [[1], [2], [3], [6], [7], [8], [9], [10]], 8], + 'remove zero rows' => [5, 0, [[1], [2], [3], [4], [5], [6], [7], [8], [9], [10]], 10], + 'remove rows above highest' => [20, 2, [[1], [2], [3], [4], [5], [6], [7], [8], [9], [10]], 10], + ]; + } } From a5e7ea2fb04cbbce7f1e03f5a2d35743973e2811 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:59:31 -0700 Subject: [PATCH 18/24] Minor Changes to Worksheet Fix #4917. --- src/PhpSpreadsheet/Worksheet/Worksheet.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index 5c26e2dd8..f6a2c4ba1 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -360,9 +360,12 @@ class Worksheet /** * Disconnect all cells from this Worksheet object, * typically so that the worksheet object can be unset. + * The worksheet will be in an unusable state after + * this method has completed. */ public function disconnectCells(): void { + // isset needed to avoid problems at destruct time if (isset($this->cellCollection)) { //* @phpstan-ignore-line $this->cellCollection->unsetWorksheetCells(); unset($this->cellCollection); @@ -462,6 +465,7 @@ class Worksheet */ public function getCoordinates(bool $sorted = true): array { + // isset needed to avoid problems at destruct time if (!isset($this->cellCollection)) { //* @phpstan-ignore-line return []; } @@ -796,8 +800,9 @@ class Worksheet $this->activePane = $holdActivePane; } if ($activeSheet !== null && $activeSheet >= 0) { - // Not sure what PhpStan doesn't like about next stmt - $this->getParent()?->setActiveSheetIndex($activeSheet); // @phpstan-ignore-line + // Okay, I get it now - if $activeSheet is not null, + // then $this->getParent() must also be non-null. + $this->getParent()->setActiveSheetIndex($activeSheet); } $this->setSelectedCells($selectedCells); @@ -1234,8 +1239,7 @@ class Worksheet throw new Exception('Sheet not found for named range: ' . $namedRange->getName()); } - /** @phpstan-ignore-next-line */ - $cellCoordinate = ltrim(substr($namedRange->getValue(), strrpos($namedRange->getValue(), '!')), '!'); + $cellCoordinate = ltrim(substr($namedRange->getValue(), (int) strrpos($namedRange->getValue(), '!')), '!'); $finalCoordinate = str_replace('$', '', $cellCoordinate); } } @@ -1683,6 +1687,7 @@ class Worksheet public function duplicateConditionalStyle(array $styles, string $range = ''): static { foreach ($styles as $cellStyle) { + // Php runtime doesn't support docblock declaration if (!($cellStyle instanceof Conditional)) { // @phpstan-ignore-line throw new Exception('Style is not a conditional style'); } From 7f331bca4cd353f09a0343a78fefe0d2d4470ff7 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:54:00 -0700 Subject: [PATCH 19/24] Clean Up Some Other Phpstan Annotations Mostly remove `phpstan-ignore-line` in a number of places. Very minor code changes to Worksheet/AutoFilter and Reader/Xlsx. --- phpstan.neon.dist | 1 + src/PhpSpreadsheet/Calculation/Calculation.php | 4 ++-- .../Financial/CashFlow/Variable/NonPeriodic.php | 2 +- src/PhpSpreadsheet/Calculation/Information/Value.php | 4 +--- src/PhpSpreadsheet/Calculation/LookupRef/Filter.php | 2 +- src/PhpSpreadsheet/Cell/Coordinate.php | 2 +- src/PhpSpreadsheet/IOFactory.php | 6 ++---- src/PhpSpreadsheet/Reader/Xlsx.php | 2 +- src/PhpSpreadsheet/Settings.php | 3 +-- .../Style/ConditionalFormatting/Wizard/CellValue.php | 3 +-- .../Style/ConditionalFormatting/Wizard/TextValue.php | 3 +-- src/PhpSpreadsheet/Worksheet/AutoFilter.php | 7 ++----- src/PhpSpreadsheet/Worksheet/Worksheet.php | 3 +-- 13 files changed, 16 insertions(+), 26 deletions(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index bbe5e3641..29ec2fc13 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -8,6 +8,7 @@ includes: parameters: level: 10 + treatPhpDocTypesAsCertain: false paths: - samples/ - src/ diff --git a/src/PhpSpreadsheet/Calculation/Calculation.php b/src/PhpSpreadsheet/Calculation/Calculation.php index f379ff1fc..09c8c20d1 100644 --- a/src/PhpSpreadsheet/Calculation/Calculation.php +++ b/src/PhpSpreadsheet/Calculation/Calculation.php @@ -640,7 +640,7 @@ class Calculation extends CalculationLocale if ($this->formulaTokenCacheMaxSize > 0) { // Phpstan says if condition is always false, // but coverage report says next statement is covered. - if (count($this->formulaTokenCache) >= $this->formulaTokenCacheMaxSize) { // @phpstan-ignore-line + if (count($this->formulaTokenCache) >= $this->formulaTokenCacheMaxSize) { $this->formulaTokenCache = []; } // Cache key is the original formula string (before ANCHORARRAY transformation) @@ -1391,7 +1391,7 @@ class Calculation extends CalculationLocale // do we now have a function/variable/number? $expectingOperator = true; $expectingOperand = false; - $val = $match[1] ?? ''; //* @phpstan-ignore-line + $val = $match[1] ?? ''; $length = strlen($val); if (preg_match('/^' . self::CALCULATION_REGEXP_FUNCTION . '$/miu', $val, $matches)) { diff --git a/src/PhpSpreadsheet/Calculation/Financial/CashFlow/Variable/NonPeriodic.php b/src/PhpSpreadsheet/Calculation/Financial/CashFlow/Variable/NonPeriodic.php index 53668d134..130e4c103 100644 --- a/src/PhpSpreadsheet/Calculation/Financial/CashFlow/Variable/NonPeriodic.php +++ b/src/PhpSpreadsheet/Calculation/Financial/CashFlow/Variable/NonPeriodic.php @@ -166,7 +166,7 @@ class NonPeriodic $foundneg = false; for ($i = 0; $i < $valCount; ++$i) { $fld = $values[$i]; - if (!is_numeric($fld)) { //* @phpstan-ignore-line + if (!is_numeric($fld)) { return ExcelError::VALUE(); } elseif ($fld > 0) { $foundpos = true; diff --git a/src/PhpSpreadsheet/Calculation/Information/Value.php b/src/PhpSpreadsheet/Calculation/Information/Value.php index 4fee352c6..8280bc5ef 100644 --- a/src/PhpSpreadsheet/Calculation/Information/Value.php +++ b/src/PhpSpreadsheet/Calculation/Information/Value.php @@ -54,9 +54,7 @@ class Value } try { - // Phpstan claims cellValue can't be null. - // I don't see why. - [$column, $row] = Coordinate::indexesFromString($cellValue ?? ''); // @phpstan-ignore-line + [$column, $row] = Coordinate::indexesFromString($cellValue ?? ''); } catch (SpreadsheetException) { return false; } diff --git a/src/PhpSpreadsheet/Calculation/LookupRef/Filter.php b/src/PhpSpreadsheet/Calculation/LookupRef/Filter.php index 486194e46..3106d4e7e 100644 --- a/src/PhpSpreadsheet/Calculation/LookupRef/Filter.php +++ b/src/PhpSpreadsheet/Calculation/LookupRef/Filter.php @@ -58,7 +58,7 @@ class Filter */ private static function filterByRow(array $lookupArray, array $matchArray): array { - $matchArray = array_values(array_column($matchArray, 0)); // @phpstan-ignore-line + $matchArray = array_values(array_column($matchArray, 0)); return array_filter( array_values($lookupArray), diff --git a/src/PhpSpreadsheet/Cell/Coordinate.php b/src/PhpSpreadsheet/Cell/Coordinate.php index f7daa9d9b..038c4b065 100644 --- a/src/PhpSpreadsheet/Cell/Coordinate.php +++ b/src/PhpSpreadsheet/Cell/Coordinate.php @@ -745,7 +745,7 @@ abstract class Coordinate } } - if ($rowStart !== null) { // @phpstan-ignore-line + if ($rowStart !== null) { if ($rowStart == $rowEnd) { $ranges[] = $hashedValue->col . $rowStart; } else { diff --git a/src/PhpSpreadsheet/IOFactory.php b/src/PhpSpreadsheet/IOFactory.php index 7444aa879..7e4010f66 100644 --- a/src/PhpSpreadsheet/IOFactory.php +++ b/src/PhpSpreadsheet/IOFactory.php @@ -265,8 +265,7 @@ abstract class IOFactory */ public static function registerWriter(string $writerType, string $writerClass): void { - // We want phpstan to validate caller, but still need this test - if (!is_a($writerClass, IWriter::class, true)) { //* @phpstan-ignore-line + if (!is_a($writerClass, IWriter::class, true)) { throw new Writer\Exception('Registered writers must implement ' . IWriter::class); } @@ -280,8 +279,7 @@ abstract class IOFactory */ public static function registerReader(string $readerType, string $readerClass): void { - // We want phpstan to validate caller, but still need this test - if (!is_a($readerClass, IReader::class, true)) { //* @phpstan-ignore-line + if (!is_a($readerClass, IReader::class, true)) { throw new Reader\Exception('Registered readers must implement ' . IReader::class); } diff --git a/src/PhpSpreadsheet/Reader/Xlsx.php b/src/PhpSpreadsheet/Reader/Xlsx.php index b05ae877e..0c0199676 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx.php +++ b/src/PhpSpreadsheet/Reader/Xlsx.php @@ -2403,7 +2403,7 @@ class Xlsx extends BaseReader { $returnValue = null; $protectKey = $protection[$key]; - if (!empty($protectKey)) { + if (isset($protectKey)) { $protectKey = (string) $protectKey; $returnValue = $protectKey !== 'false' && (bool) $protectKey; } diff --git a/src/PhpSpreadsheet/Settings.php b/src/PhpSpreadsheet/Settings.php index 9c2beaa03..7759d9250 100644 --- a/src/PhpSpreadsheet/Settings.php +++ b/src/PhpSpreadsheet/Settings.php @@ -52,8 +52,7 @@ class Settings */ public static function setChartRenderer(string $rendererClassName): void { - // We want phpstan to validate caller, but still need this test - if (!is_a($rendererClassName, IRenderer::class, true)) { //* @phpstan-ignore-line + if (!is_a($rendererClassName, IRenderer::class, true)) { throw new Exception('Chart renderer must implement ' . IRenderer::class); } diff --git a/src/PhpSpreadsheet/Style/ConditionalFormatting/Wizard/CellValue.php b/src/PhpSpreadsheet/Style/ConditionalFormatting/Wizard/CellValue.php index 435e6a60d..6d23b9d92 100644 --- a/src/PhpSpreadsheet/Style/ConditionalFormatting/Wizard/CellValue.php +++ b/src/PhpSpreadsheet/Style/ConditionalFormatting/Wizard/CellValue.php @@ -192,8 +192,7 @@ class CellValue extends WizardAbstract implements WizardInterface $retVal = true; $array = array_merge(array_keys(self::SINGLE_OPERATORS), array_keys(self::RANGE_OPERATORS)); foreach ($array as $value) { - // PhpStan is correct about next statement, but we want to test anyhow - $retVal = $retVal && in_array($value, self::MAGIC_OPERATIONS, true); // @phpstan-ignore-line + $retVal = $retVal && in_array($value, self::MAGIC_OPERATIONS, true); } return $retVal; diff --git a/src/PhpSpreadsheet/Style/ConditionalFormatting/Wizard/TextValue.php b/src/PhpSpreadsheet/Style/ConditionalFormatting/Wizard/TextValue.php index 934a364ab..c1657666d 100644 --- a/src/PhpSpreadsheet/Style/ConditionalFormatting/Wizard/TextValue.php +++ b/src/PhpSpreadsheet/Style/ConditionalFormatting/Wizard/TextValue.php @@ -169,8 +169,7 @@ class TextValue extends WizardAbstract implements WizardInterface $retVal = true; $array = array_keys(self::OPERATORS); foreach ($array as $value) { - // PhpStan is correct about next statement, but we want to test anyhow - $retVal = $retVal && in_array($value, self::MAGIC_OPERATIONS, true); // @phpstan-ignore-line + $retVal = $retVal && in_array($value, self::MAGIC_OPERATIONS, true); } return $retVal; diff --git a/src/PhpSpreadsheet/Worksheet/AutoFilter.php b/src/PhpSpreadsheet/Worksheet/AutoFilter.php index 04070e5d9..05afcfb24 100644 --- a/src/PhpSpreadsheet/Worksheet/AutoFilter.php +++ b/src/PhpSpreadsheet/Worksheet/AutoFilter.php @@ -752,14 +752,11 @@ class AutoFilter implements Stringable private function dynamicFilterDateRange(string $dynamicRuleType, AutoFilter\Column &$filterColumn): array { $ruleValues = []; - $callBack = [__CLASS__, self::DATE_FUNCTIONS[$dynamicRuleType]]; // What if not found? + $callBack = [__CLASS__, self::DATE_FUNCTIONS[$dynamicRuleType] ?? throw new Exception("invalid dynamic rule type $dynamicRuleType")]; // Calculate start/end dates for the required date range based on current date // Val is lowest permitted value. // Maxval is greater than highest permitted value - $val = $maxval = 0; - if (is_callable($callBack)) { //* @phpstan-ignore-line - [$val, $maxval] = $callBack(); - } + [$val, $maxval] = $callBack(); $val = Date::dateTimeToExcel($val); $maxval = Date::dateTimeToExcel($maxval); diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index f6a2c4ba1..d3e9e4dd3 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -1687,8 +1687,7 @@ class Worksheet public function duplicateConditionalStyle(array $styles, string $range = ''): static { foreach ($styles as $cellStyle) { - // Php runtime doesn't support docblock declaration - if (!($cellStyle instanceof Conditional)) { // @phpstan-ignore-line + if (!($cellStyle instanceof Conditional)) { throw new Exception('Style is not a conditional style'); } } From 13edf0d8a22784f6cd7660a0f61c2ecc266d599c Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:08:15 -0700 Subject: [PATCH 20/24] More AutoFilter Tweaks --- src/PhpSpreadsheet/Worksheet/AutoFilter.php | 74 ++++++------------- .../Worksheet/AutoFilter/ColumnTest.php | 8 ++ .../Worksheet/AutoFilter/RuleCustom2Test.php | 62 ++++++++++++++++ 3 files changed, 93 insertions(+), 51 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Worksheet/AutoFilter/RuleCustom2Test.php diff --git a/src/PhpSpreadsheet/Worksheet/AutoFilter.php b/src/PhpSpreadsheet/Worksheet/AutoFilter.php index 05afcfb24..ad7330252 100644 --- a/src/PhpSpreadsheet/Worksheet/AutoFilter.php +++ b/src/PhpSpreadsheet/Worksheet/AutoFilter.php @@ -2,6 +2,7 @@ namespace PhpOffice\PhpSpreadsheet\Worksheet; +use Composer\Pcre\Preg; use DateTime; use DateTimeZone; use PhpOffice\PhpSpreadsheet\Calculation\Calculation; @@ -146,7 +147,7 @@ class AutoFilter implements Stringable $this->evaluated = false; if ($this->workSheet !== null) { $thisrange = $this->range; - $range = (string) preg_replace('/\d+$/', (string) $this->workSheet->getHighestRow(), $thisrange); + $range = Preg::replace('/\d+$/', (string) $this->workSheet->getHighestRow(), $thisrange); if ($range !== $thisrange) { $this->setRange($range); } @@ -239,12 +240,10 @@ class AutoFilter implements Stringable public function setColumn(AutoFilter\Column|string $columnObjectOrString): static { $this->evaluated = false; - if ((is_string($columnObjectOrString)) && (!empty($columnObjectOrString))) { + if (is_string($columnObjectOrString)) { $column = $columnObjectOrString; - } elseif ($columnObjectOrString instanceof AutoFilter\Column) { - $column = $columnObjectOrString->getColumnIndex(); } else { - throw new Exception('Column is not within the autofilter range.'); + $column = $columnObjectOrString->getColumnIndex(); } $this->testColumnInRange($column); @@ -434,32 +433,15 @@ class AutoFilter implements Stringable }; } else { // String values are always tested for equality, factoring in for wildcards (hence a regexp test) - switch ($ruleOperator) { - case Rule::AUTOFILTER_COLUMN_RULE_EQUAL: - $retVal = (bool) preg_match('/^' . $ruleValue . '$/i', $cellValueString); - - break; - case Rule::AUTOFILTER_COLUMN_RULE_NOTEQUAL: - $retVal = !((bool) preg_match('/^' . $ruleValue . '$/i', $cellValueString)); - - break; - case Rule::AUTOFILTER_COLUMN_RULE_GREATERTHAN: - $retVal = strcasecmp($cellValueString, $ruleValue) > 0; - - break; - case Rule::AUTOFILTER_COLUMN_RULE_GREATERTHANOREQUAL: - $retVal = strcasecmp($cellValueString, $ruleValue) >= 0; - - break; - case Rule::AUTOFILTER_COLUMN_RULE_LESSTHAN: - $retVal = strcasecmp($cellValueString, $ruleValue) < 0; - - break; - case Rule::AUTOFILTER_COLUMN_RULE_LESSTHANOREQUAL: - $retVal = strcasecmp($cellValueString, $ruleValue) <= 0; - - break; - } + $retVal = match ($ruleOperator) { + Rule::AUTOFILTER_COLUMN_RULE_EQUAL => Preg::isMatch('/^' . $ruleValue . '$/i', $cellValueString), + Rule::AUTOFILTER_COLUMN_RULE_NOTEQUAL => !(Preg::isMatch('/^' . $ruleValue . '$/i', $cellValueString)), + Rule::AUTOFILTER_COLUMN_RULE_GREATERTHAN => strcasecmp($cellValueString, $ruleValue) > 0, + Rule::AUTOFILTER_COLUMN_RULE_GREATERTHANOREQUAL => strcasecmp($cellValueString, $ruleValue) >= 0, + Rule::AUTOFILTER_COLUMN_RULE_LESSTHAN => strcasecmp($cellValueString, $ruleValue) < 0, + // Rule::AUTOFILTER_COLUMN_RULE_LESSTHANOREQUAL + default => strcasecmp($cellValueString, $ruleValue) <= 0, + }; } // If there are multiple conditions, then we need to test both using the appropriate join operator switch ($join) { @@ -1084,26 +1066,16 @@ class AutoFilter implements Stringable */ 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\AutoFilter objects - $this->{$key} = []; - foreach ($value as $k => $v) { - $this->{$key}[$k] = clone $v; //* @phpstan-ignore-line - // attach the new cloned Column to this new cloned Autofilter object - $this->{$key}[$k]->setParent($this); //* @phpstan-ignore-line - } - } else { - $this->{$key} = $value; - } + // workSheet is only property which is an object + // detach from worksheet + $this->workSheet = null; + // columns is the only property which is an array + $columns = $this->columns; + $this->columns = []; + foreach ($columns as $k => $v) { + $this->columns[$k] = clone $v; + // attach the new cloned Column to this new cloned Autofilter object + $this->columns[$k]->setParent($this); } } diff --git a/tests/PhpSpreadsheetTests/Worksheet/AutoFilter/ColumnTest.php b/tests/PhpSpreadsheetTests/Worksheet/AutoFilter/ColumnTest.php index e12b2d0ca..5463cd6c3 100644 --- a/tests/PhpSpreadsheetTests/Worksheet/AutoFilter/ColumnTest.php +++ b/tests/PhpSpreadsheetTests/Worksheet/AutoFilter/ColumnTest.php @@ -51,6 +51,14 @@ class ColumnTest extends SetupTeardown $sheet->getAutoFilter()->getColumn('B'); } + public function testSetBadColumnIndex(): void + { + $this->expectException(PhpSpreadsheetException::class); + $this->expectExceptionMessage('Column string index can not be empty'); + $sheet = $this->initSheet(); + $sheet->getAutoFilter()->setColumn(''); + } + public function testSetColumnIndex(): void { $sheet = $this->initSheet(); diff --git a/tests/PhpSpreadsheetTests/Worksheet/AutoFilter/RuleCustom2Test.php b/tests/PhpSpreadsheetTests/Worksheet/AutoFilter/RuleCustom2Test.php new file mode 100644 index 000000000..a5fd2c2fd --- /dev/null +++ b/tests/PhpSpreadsheetTests/Worksheet/AutoFilter/RuleCustom2Test.php @@ -0,0 +1,62 @@ +getSheet(); + $sheet->getCell('A1')->setValue('Heading'); + $sheet->getCell('A2')->setValue(2); + //$sheet->getCell('A3')->setValue(3); + $sheet->getCell('A4')->setValue(4); + $sheet->getCell('B1')->setValue('Heading2'); + $sheet->getCell('B2')->setValue(1); + $sheet->getCell('B3')->setValue(2); + $sheet->getCell('B4')->setValue(3); + $this->maxRow = $maxRow = 4; + $autoFilter = $sheet->getAutoFilter(); + $autoFilter->setRange("A1:B$maxRow"); + + return $sheet; + } + + /** @param int[] $expectedResult */ + #[DataProvider('providerCondition')] + public function testComparisonToNullString(array $expectedResult, string $condition): void + { + $sheet = $this->initSheet(); + $columnFilter = $sheet->getAutoFilter()->getColumn('A'); + $columnFilter->setFilterType(Column::AUTOFILTER_FILTERTYPE_CUSTOMFILTER); + $columnFilter->createRule() + ->setRule( + $condition, + '' + ) + ->setRuleType(Rule::AUTOFILTER_RULETYPE_CUSTOMFILTER); + self::assertEquals($expectedResult, $this->getVisible()); + } + + /** @return array */ + public static function providerCondition(): array + { + // Comparing to null-string, equal and notequal work as expected. + // Other conditions filter everything. + return [ + [[3], Rule::AUTOFILTER_COLUMN_RULE_EQUAL], + [[2, 4], Rule::AUTOFILTER_COLUMN_RULE_NOTEQUAL], + [[2, 3, 4], Rule::AUTOFILTER_COLUMN_RULE_GREATERTHAN], + [[2, 3, 4], Rule::AUTOFILTER_COLUMN_RULE_GREATERTHANOREQUAL], + [[2, 3, 4], Rule::AUTOFILTER_COLUMN_RULE_LESSTHAN], + [[2, 3, 4], Rule::AUTOFILTER_COLUMN_RULE_LESSTHANOREQUAL], + ]; + } +} From 5b57325f8c5bae55f3ff8d9d9a3e3afed3656203 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Sun, 28 Jun 2026 20:41:07 -0700 Subject: [PATCH 21/24] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e003679ca..3d009de5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a - Small change to Writer Html. [Issue #434](https://github.com/PHPOffice/PhpSpreadsheet/issues/434) [PR #4912](https://github.com/PHPOffice/PhpSpreadsheet/pull/4912) - Avoid hard-coding some chart Xml attributes. [PR #4915](https://github.com/PHPOffice/PhpSpreadsheet/pull/4915) - Readers should directly access readFilter. [PR #4919](https://github.com/PHPOffice/PhpSpreadsheet/pull/4919) +- Very minor changes to Worksheet, Reader/Xlsx, and Worksheet/AutoFilter. [Issue #4917](https://github.com/PHPOffice/PhpSpreadsheet/issues/4917) [PR #4926](https://github.com/PHPOffice/PhpSpreadsheet/pull/4926) +- Consistent HighestRow/Column after row/columnDelete. [Issue #943](https://github.com/PHPOffice/PhpSpreadsheet/issues/943) [PR #4925](https://github.com/PHPOffice/PhpSpreadsheet/pull/4925) ## 2026-06-06 - 5.8.0 From 0532808c46c5031159a2eeb8337f9e6deb6a2c31 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:02:49 +0000 Subject: [PATCH 22/24] Bump friendsofphp/php-cs-fixer from 3.95.4 to 3.95.11 Bumps [friendsofphp/php-cs-fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer) from 3.95.4 to 3.95.11. - [Release notes](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/releases) - [Changelog](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/CHANGELOG.md) - [Commits](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/compare/v3.95.4...v3.95.11) --- updated-dependencies: - dependency-name: friendsofphp/php-cs-fixer dependency-version: 3.95.11 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- composer.lock | 121 ++++++++++++++++++++++++-------------------------- 1 file changed, 59 insertions(+), 62 deletions(-) diff --git a/composer.lock b/composer.lock index 2386f44d0..e0dba568d 100644 --- a/composer.lock +++ b/composer.lock @@ -8,28 +8,29 @@ "packages": [ { "name": "composer/pcre", - "version": "3.3.2", + "version": "3.4.0", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", "shasum": "" }, "require": { "php": "^7.4 || ^8.0" }, "conflict": { - "phpstan/phpstan": "<1.11.10" + "phpstan/phpstan": "<2.2.2" }, "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-strict-rules": "^1 || ^2", - "phpunit/phpunit": "^8 || ^9" + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" }, "type": "library", "extra": { @@ -67,7 +68,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.2" + "source": "https://github.com/composer/pcre/tree/3.4.0" }, "funding": [ { @@ -77,13 +78,9 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2024-11-12T16:29:46+00:00" + "time": "2026-06-07T11:47:49+00:00" }, { "name": "maennchen/zipstream-php", @@ -960,16 +957,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.95.4", + "version": "v3.95.11", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "3f8f68856837a77e1f1d870354eca3c8747f2f72" + "reference": "35f98e1293283397824d7f349ce5afb8747c3cd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/3f8f68856837a77e1f1d870354eca3c8747f2f72", - "reference": "3f8f68856837a77e1f1d870354eca3c8747f2f72", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/35f98e1293283397824d7f349ce5afb8747c3cd5", + "reference": "35f98e1293283397824d7f349ce5afb8747c3cd5", "shasum": "" }, "require": { @@ -987,7 +984,7 @@ "react/event-loop": "^1.5", "react/socket": "^1.16", "react/stream": "^1.4", - "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0", "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", @@ -1003,16 +1000,16 @@ "require-dev": { "facile-it/paraunit": "^1.3.1 || ^2.11.0", "infection/infection": "^0.32.7", - "justinrainbow/json-schema": "^6.8.0", + "justinrainbow/json-schema": "^6.10.0", "keradus/cli-executor": "^2.3", "mikey179/vfsstream": "^1.6.12", "php-coveralls/php-coveralls": "^2.9.1", "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.55", - "symfony/polyfill-php85": "^1.37", - "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.8", - "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.11" + "symfony/polyfill-php85": "^1.38", + "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.0", + "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.0" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -1053,7 +1050,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.4" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.11" }, "funding": [ { @@ -1061,7 +1058,7 @@ "type": "github" } ], - "time": "2026-06-03T18:02:44+00:00" + "time": "2026-06-25T14:17:04+00:00" }, { "name": "masterminds/html5", @@ -4225,16 +4222,16 @@ }, { "name": "symfony/console", - "version": "v6.4.41", + "version": "v6.4.42", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "d21b17ed158e79180fac3895ff751707970eeb57" + "reference": "9ef84af84a7b66396da483634227650506428639" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/d21b17ed158e79180fac3895ff751707970eeb57", - "reference": "d21b17ed158e79180fac3895ff751707970eeb57", + "url": "https://api.github.com/repos/symfony/console/zipball/9ef84af84a7b66396da483634227650506428639", + "reference": "9ef84af84a7b66396da483634227650506428639", "shasum": "" }, "require": { @@ -4299,7 +4296,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.41" + "source": "https://github.com/symfony/console/tree/v6.4.42" }, "funding": [ { @@ -4319,20 +4316,20 @@ "type": "tidelift" } ], - "time": "2026-05-24T08:48:41+00:00" + "time": "2026-06-15T05:35:29+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -4370,7 +4367,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -4390,7 +4387,7 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/event-dispatcher", @@ -4478,16 +4475,16 @@ }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -4534,7 +4531,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -4554,7 +4551,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/filesystem", @@ -4628,16 +4625,16 @@ }, { "name": "symfony/finder", - "version": "v6.4.34", + "version": "v6.4.42", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "9590e86be1d1c57bfbb16d0dd040345378c20896" + "reference": "0b73dac42493acbadbba644207a715b254e9b029" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/9590e86be1d1c57bfbb16d0dd040345378c20896", - "reference": "9590e86be1d1c57bfbb16d0dd040345378c20896", + "url": "https://api.github.com/repos/symfony/finder/zipball/0b73dac42493acbadbba644207a715b254e9b029", + "reference": "0b73dac42493acbadbba644207a715b254e9b029", "shasum": "" }, "require": { @@ -4672,7 +4669,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.34" + "source": "https://github.com/symfony/finder/tree/v6.4.42" }, "funding": [ { @@ -4692,7 +4689,7 @@ "type": "tidelift" } ], - "time": "2026-01-28T15:16:37+00:00" + "time": "2026-06-26T15:18:24+00:00" }, { "name": "symfony/options-resolver", @@ -5017,16 +5014,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.38.1", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { @@ -5078,7 +5075,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -5098,7 +5095,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php80", @@ -5411,16 +5408,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { @@ -5474,7 +5471,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -5494,7 +5491,7 @@ "type": "tidelift" } ], - "time": "2026-03-28T09:44:51+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { "name": "symfony/stopwatch", @@ -5952,5 +5949,5 @@ "platform-overrides": { "php": "8.1.99" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } From 1fc375baf51a40f624dd8f3babc598cfe84d19df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:02:56 +0000 Subject: [PATCH 23/24] Bump composer/pcre from 3.3.2 to 3.4.0 Bumps [composer/pcre](https://github.com/composer/pcre) from 3.3.2 to 3.4.0. - [Release notes](https://github.com/composer/pcre/releases) - [Commits](https://github.com/composer/pcre/compare/3.3.2...3.4.0) --- updated-dependencies: - dependency-name: composer/pcre dependency-version: 3.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- composer.lock | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/composer.lock b/composer.lock index 2386f44d0..a66ea27d0 100644 --- a/composer.lock +++ b/composer.lock @@ -8,28 +8,29 @@ "packages": [ { "name": "composer/pcre", - "version": "3.3.2", + "version": "3.4.0", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", "shasum": "" }, "require": { "php": "^7.4 || ^8.0" }, "conflict": { - "phpstan/phpstan": "<1.11.10" + "phpstan/phpstan": "<2.2.2" }, "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-strict-rules": "^1 || ^2", - "phpunit/phpunit": "^8 || ^9" + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" }, "type": "library", "extra": { @@ -67,7 +68,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.2" + "source": "https://github.com/composer/pcre/tree/3.4.0" }, "funding": [ { @@ -77,13 +78,9 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2024-11-12T16:29:46+00:00" + "time": "2026-06-07T11:47:49+00:00" }, { "name": "maennchen/zipstream-php", @@ -5952,5 +5949,5 @@ "platform-overrides": { "php": "8.1.99" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } From 9533f5903fd4dd96ac170a7b49042ee2f8ac2a01 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:33:46 -0700 Subject: [PATCH 24/24] Update Phpstan --- composer.lock | 28 ++++++++++--------- .../Calculation/Calculation.php | 2 +- src/PhpSpreadsheet/Reader/Gnumeric/Styles.php | 8 ++---- .../Style/NumberFormat/NumberFormatter.php | 4 +-- 4 files changed, 19 insertions(+), 23 deletions(-) diff --git a/composer.lock b/composer.lock index e0dba568d..d085bbdbb 100644 --- a/composer.lock +++ b/composer.lock @@ -1706,11 +1706,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.2", + "version": "2.2.3", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e5cc34d491a90e79c216d824f60fe21fd4d93bd6", - "reference": "e5cc34d491a90e79c216d824f60fe21fd4d93bd6", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4048833dd47b377287818841877fb3087289509c", + "reference": "4048833dd47b377287818841877fb3087289509c", "shasum": "" }, "require": { @@ -1766,7 +1766,7 @@ "type": "github" } ], - "time": "2026-06-05T09:00:01+00:00" + "time": "2026-06-30T21:15:26+00:00" }, { "name": "phpstan/phpstan-deprecation-rules", @@ -1820,21 +1820,22 @@ }, { "name": "phpstan/phpstan-phpunit", - "version": "2.0.16", + "version": "2.0.17", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-phpunit.git", - "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32" + "reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6ab598e1bc106e6827fd346ae4a12b4a5d634c32", - "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/c2f977551f0736d60467b3d754b2e0cf4e337b3f", + "reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f", "shasum": "" }, "require": { + "phar-io/version": "^3.2", "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.1.32" + "phpstan/phpstan": "^2.2.3" }, "conflict": { "phpunit/phpunit": "<7.0" @@ -1844,7 +1845,8 @@ "php-parallel-lint/php-parallel-lint": "^1.2", "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^9.6", + "shipmonk/name-collision-detector": "^2.1" }, "type": "phpstan-extension", "extra": { @@ -1870,9 +1872,9 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-phpunit/issues", - "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.16" + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.17" }, - "time": "2026-02-14T09:05:21+00:00" + "time": "2026-06-29T05:32:23+00:00" }, { "name": "phpunit/php-code-coverage", @@ -5949,5 +5951,5 @@ "platform-overrides": { "php": "8.1.99" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/src/PhpSpreadsheet/Calculation/Calculation.php b/src/PhpSpreadsheet/Calculation/Calculation.php index 09c8c20d1..da7115344 100644 --- a/src/PhpSpreadsheet/Calculation/Calculation.php +++ b/src/PhpSpreadsheet/Calculation/Calculation.php @@ -1955,7 +1955,7 @@ class Calculation extends CalculationLocale if ($breakNeeded) { break; } - $cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' . Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow); // @phpstan-ignore-line + $cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' . Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow); if ($pCellParent !== null && $this->spreadsheet !== null) { $cellValue = $this->extractCellRange($cellRef, $this->spreadsheet->getSheetByName($sheet1), false); } else { diff --git a/src/PhpSpreadsheet/Reader/Gnumeric/Styles.php b/src/PhpSpreadsheet/Reader/Gnumeric/Styles.php index 85ff4e8ad..0729f2ef2 100644 --- a/src/PhpSpreadsheet/Reader/Gnumeric/Styles.php +++ b/src/PhpSpreadsheet/Reader/Gnumeric/Styles.php @@ -130,14 +130,10 @@ class Styles $styleArray['borders']['diagonal'] = self::parseBorderAttributes($srssb->Diagonal->attributes()); $styleArray['borders']['diagonalDirection'] = Borders::DIAGONAL_BOTH; } elseif (isset($srssb->Diagonal)) { - // No idea what Phpstan objects to, - // especially since it has no problem with identical - // statement just above - $styleArray['borders']['diagonal'] = self::parseBorderAttributes($srssb->Diagonal->attributes()); // @phpstan-ignore-line + $styleArray['borders']['diagonal'] = self::parseBorderAttributes($srssb->Diagonal->attributes()); $styleArray['borders']['diagonalDirection'] = Borders::DIAGONAL_UP; } elseif (isset($srssb->{'Rev-Diagonal'})) { - // No idea what Phpstan objects to - $styleArray['borders']['diagonal'] = self::parseBorderAttributes($srssb->{'Rev-Diagonal'}->attributes()); // @phpstan-ignore-line + $styleArray['borders']['diagonal'] = self::parseBorderAttributes($srssb->{'Rev-Diagonal'}->attributes()); $styleArray['borders']['diagonalDirection'] = Borders::DIAGONAL_DOWN; } } diff --git a/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php b/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php index f869d0736..7dcf81f58 100644 --- a/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php +++ b/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php @@ -55,10 +55,8 @@ class NumberFormatter extends BaseFormatter $number = floor($numberFloat / $divisor); $mask = substr_replace($mask, $blockValue, $offset, $size); } - /** @var string $numberString */ - $numberString = $number; if ($number > 0) { - $mask = substr_replace($mask, $numberString, $offset, 0); + $mask = substr_replace($mask, "$number", $offset, 0); } $result = $mask; }