From 2fd3ac5552796a3cf29f9749472c3744e19ecbfc Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 28 Aug 2025 13:44:32 -0700 Subject: [PATCH] Option to Create Blank Sheet If LoadSheetsOnly Doesn't Find Any In PhpSpreadsheet Release 1, if the LoadSheetsOnly option was specified, and no sheets matched, a new blank sheet was created. This behavior changed in PhpSpreadsheet Release 2, so that an exception wound up being thrown instead. Although the Release 2 approach seems more sensible to me, it was actually collateral damage from a different change, and was not an intentional result. The difference in behavior is causing a problem for Laravel-Excel. In particular, a PR which would move their supported PhpSpreadsheet release from 1 to 5, is delayed because this change in behavior breaks part of their test suite. See https://github.com/SpartnerNL/Laravel-Excel/pull/4302. We would very much like them to get off release 1. I volunteered to add a compatibility option to the Readers which would emulate the release 1 behavior. The result is this PR. Usage: ```php $reader = new \PhpOffice\PhpSpreadsheet\Reader\Xlsx(); $reader->setLoadSheetsOnly([list of sheet names]); if (method_exists($reader, 'setCreateBlankSheetIfNoneRead')) { $reader->setCreateBlankSheetIfNoneRead(true); } ``` In addition to Xlsx Reader, the method is available for Xls, Ods, Xml, and Gnumeric. --- src/PhpSpreadsheet/Reader/BaseReader.php | 20 +++++++ src/PhpSpreadsheet/Reader/Gnumeric.php | 5 ++ src/PhpSpreadsheet/Reader/IReader.php | 9 +++ src/PhpSpreadsheet/Reader/Ods.php | 5 ++ .../Reader/Xls/LoadSpreadsheet.php | 5 ++ src/PhpSpreadsheet/Reader/Xlsx.php | 5 ++ src/PhpSpreadsheet/Reader/Xml.php | 5 ++ .../Reader/CreateBlankSheetIfNoneReadTest.php | 56 +++++++++++++++++++ 8 files changed, 110 insertions(+) create mode 100644 tests/PhpSpreadsheetTests/Reader/CreateBlankSheetIfNoneReadTest.php diff --git a/src/PhpSpreadsheet/Reader/BaseReader.php b/src/PhpSpreadsheet/Reader/BaseReader.php index bdc4be50a..c4184bdd4 100644 --- a/src/PhpSpreadsheet/Reader/BaseReader.php +++ b/src/PhpSpreadsheet/Reader/BaseReader.php @@ -54,6 +54,12 @@ abstract class BaseReader implements IReader */ protected bool $allowExternalImages = false; + /** + * Create a blank sheet if none are read, + * possibly due to a typo when using LoadSheetsOnly. + */ + protected bool $createBlankSheetIfNoneRead = false; + /** * IReadFilter instance. */ @@ -173,6 +179,17 @@ abstract class BaseReader implements IReader return $this->allowExternalImages; } + /** + * Create a blank sheet if none are read, + * possibly due to a typo when using LoadSheetsOnly. + */ + public function setCreateBlankSheetIfNoneRead(bool $createBlankSheetIfNoneRead): self + { + $this->createBlankSheetIfNoneRead = $createBlankSheetIfNoneRead; + + return $this; + } + public function getSecurityScanner(): ?XmlScanner { return $this->securityScanner; @@ -207,6 +224,9 @@ abstract class BaseReader implements IReader if (((bool) ($flags & self::DONT_ALLOW_EXTERNAL_IMAGES)) === true) { $this->setAllowExternalImages(false); } + if (((bool) ($flags & self::CREATE_BLANK_SHEET_IF_NONE_READ)) === true) { + $this->setCreateBlankSheetIfNoneRead(true); + } } protected function loadSpreadsheetFromFile(string $filename): Spreadsheet diff --git a/src/PhpSpreadsheet/Reader/Gnumeric.php b/src/PhpSpreadsheet/Reader/Gnumeric.php index ed932388c..5e683605c 100644 --- a/src/PhpSpreadsheet/Reader/Gnumeric.php +++ b/src/PhpSpreadsheet/Reader/Gnumeric.php @@ -269,6 +269,7 @@ class Gnumeric extends BaseReader (new Properties($this->spreadsheet))->readProperties($xml, $gnmXML); $worksheetID = 0; + $sheetCreated = false; foreach ($gnmXML->Sheets->Sheet as $sheetOrNull) { $sheet = self::testSimpleXml($sheetOrNull); $worksheetName = (string) $sheet->Name; @@ -280,6 +281,7 @@ class Gnumeric extends BaseReader // Create new Worksheet $this->spreadsheet->createSheet(); + $sheetCreated = true; $this->spreadsheet->setActiveSheetIndex($worksheetID); // Use false for $updateFormulaCellReferences to prevent adjustment of worksheet references in formula // cells... during the load, all formulae should be correct, and we're simply bringing the worksheet @@ -329,6 +331,9 @@ class Gnumeric extends BaseReader $this->setSelectedCells($sheet); ++$worksheetID; } + if ($this->createBlankSheetIfNoneRead && !$sheetCreated) { + $this->spreadsheet->createSheet(); + } $this->processDefinedNames($gnmXML); diff --git a/src/PhpSpreadsheet/Reader/IReader.php b/src/PhpSpreadsheet/Reader/IReader.php index eadce949e..5f2890ab1 100644 --- a/src/PhpSpreadsheet/Reader/IReader.php +++ b/src/PhpSpreadsheet/Reader/IReader.php @@ -41,6 +41,8 @@ interface IReader public const ALLOW_EXTERNAL_IMAGES = 16; public const DONT_ALLOW_EXTERNAL_IMAGES = 32; + public const CREATE_BLANK_SHEET_IF_NONE_READ = 64; + public function __construct(); /** @@ -149,6 +151,12 @@ interface IReader public function getAllowExternalImages(): bool; + /** + * Create a blank sheet if none are read, + * possibly due to a typo when using LoadSheetsOnly. + */ + public function setCreateBlankSheetIfNoneRead(bool $createBlankSheetIfNoneRead): self; + /** * Loads PhpSpreadsheet from file. * @@ -161,6 +169,7 @@ interface IReader * self::IGNORE_ROWS_WITH_NO_CELLS Don't load any rows that contain no cells. * self::ALLOW_EXTERNAL_IMAGES Attempt to fetch images stored outside the spreadsheet. * self::DONT_ALLOW_EXTERNAL_IMAGES Don't attempt to fetch images stored outside the spreadsheet. + * self::CREATE_BLANK_SHEET_IF_NONE_READ If no sheets are read, create a blank one. */ public function load(string $filename, int $flags = 0): Spreadsheet; } diff --git a/src/PhpSpreadsheet/Reader/Ods.php b/src/PhpSpreadsheet/Reader/Ods.php index e817c8977..d243f4db0 100644 --- a/src/PhpSpreadsheet/Reader/Ods.php +++ b/src/PhpSpreadsheet/Reader/Ods.php @@ -318,6 +318,7 @@ class Ods extends BaseReader $tables = $workbookData->getElementsByTagNameNS($tableNs, 'table'); $worksheetID = 0; + $sheetCreated = false; foreach ($tables as $worksheetDataSet) { /** @var DOMElement $worksheetDataSet */ $worksheetName = $worksheetDataSet->getAttributeNS($tableNs, 'name'); @@ -335,6 +336,7 @@ class Ods extends BaseReader // Create sheet $spreadsheet->createSheet(); + $sheetCreated = true; $spreadsheet->setActiveSheetIndex($worksheetID); if ($worksheetName || is_numeric($worksheetName)) { @@ -441,6 +443,9 @@ class Ods extends BaseReader ); ++$worksheetID; } + if ($this->createBlankSheetIfNoneRead && !$sheetCreated) { + $spreadsheet->createSheet(); + } $autoFilterReader->read($workbookData); $definedNameReader->read($workbookData); diff --git a/src/PhpSpreadsheet/Reader/Xls/LoadSpreadsheet.php b/src/PhpSpreadsheet/Reader/Xls/LoadSpreadsheet.php index fb28f398f..dda8aadb1 100644 --- a/src/PhpSpreadsheet/Reader/Xls/LoadSpreadsheet.php +++ b/src/PhpSpreadsheet/Reader/Xls/LoadSpreadsheet.php @@ -153,6 +153,7 @@ class LoadSpreadsheet extends Xls // Parse the individual sheets $xls->activeSheetSet = false; + $sheetCreated = false; foreach ($xls->sheets as $sheet) { $selectedCells = ''; if ($sheet['sheetType'] != 0x00) { @@ -167,6 +168,7 @@ class LoadSpreadsheet extends Xls // add sheet to PhpSpreadsheet object $xls->phpSheet = $xls->spreadsheet->createSheet(); + $sheetCreated = true; // Use false for $updateFormulaCellReferences to prevent adjustment of worksheet references in formula // cells... during the load, all formulae should be correct, and we're simply bringing the worksheet // name in line with the formula, not the reverse @@ -582,6 +584,9 @@ class LoadSpreadsheet extends Xls $xls->phpSheet->setSelectedCells($selectedCells); } } + if ($xls->createBlankSheetIfNoneRead && !$sheetCreated) { + $xls->spreadsheet->createSheet(); + } if ($xls->activeSheetSet === false) { $xls->spreadsheet->setActiveSheetIndex(0); } diff --git a/src/PhpSpreadsheet/Reader/Xlsx.php b/src/PhpSpreadsheet/Reader/Xlsx.php index 19191df4c..6d36d714c 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx.php +++ b/src/PhpSpreadsheet/Reader/Xlsx.php @@ -784,6 +784,7 @@ class Xlsx extends BaseReader $charts = $chartDetails = []; + $sheetCreated = false; if ($xmlWorkbookNS->sheets) { foreach ($xmlWorkbookNS->sheets->sheet as $eleSheet) { $eleSheetAttr = self::getAttributes($eleSheet); @@ -810,6 +811,7 @@ class Xlsx extends BaseReader // Load sheet $docSheet = $excel->createSheet(); + $sheetCreated = true; // Use false for $updateFormulaCellReferences to prevent adjustment of worksheet // references in formula cells... during the load, all formulae should be correct, // and we're simply bringing the worksheet name in line with the formula, not the @@ -1897,6 +1899,9 @@ class Xlsx extends BaseReader } } } + if ($this->createBlankSheetIfNoneRead && !$sheetCreated) { + $excel->createSheet(); + } (new WorkbookView($excel))->viewSettings($xmlWorkbook, $mainNS, $mapSheetId, $this->readDataOnly); diff --git a/src/PhpSpreadsheet/Reader/Xml.php b/src/PhpSpreadsheet/Reader/Xml.php index 6a9ed939c..08aa7f432 100644 --- a/src/PhpSpreadsheet/Reader/Xml.php +++ b/src/PhpSpreadsheet/Reader/Xml.php @@ -307,6 +307,7 @@ class Xml extends BaseReader $worksheetID = 0; $xml_ss = $xml->children(self::NAMESPACES_SS); + $sheetCreated = false; /** @var null|SimpleXMLElement $worksheetx */ foreach ($xml_ss->Worksheet as $worksheetx) { $worksheet = $worksheetx ?? new SimpleXMLElement(''); @@ -321,6 +322,7 @@ class Xml extends BaseReader // Create new Worksheet $spreadsheet->createSheet(); + $sheetCreated = true; $spreadsheet->setActiveSheetIndex($worksheetID); $worksheetName = ''; if (isset($worksheet_ss['Name'])) { @@ -668,6 +670,9 @@ class Xml extends BaseReader } ++$worksheetID; } + if ($this->createBlankSheetIfNoneRead && !$sheetCreated) { + $spreadsheet->createSheet(); + } // Globally scoped defined names $activeSheetIndex = 0; diff --git a/tests/PhpSpreadsheetTests/Reader/CreateBlankSheetIfNoneReadTest.php b/tests/PhpSpreadsheetTests/Reader/CreateBlankSheetIfNoneReadTest.php new file mode 100644 index 000000000..f84e75783 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/CreateBlankSheetIfNoneReadTest.php @@ -0,0 +1,56 @@ +expectException(SpreadsheetException::class); + $this->expectExceptionMessage('out of bounds index: 0'); + $actual = IOFactory::identify($file); + self::assertSame($expectedName, $actual); + $reader = IOFactory::createReaderForFile($file); + self::assertSame($expectedClass, $reader::class); + $sheetlist = ['Unknown sheetname']; + $reader->setLoadSheetsOnly($sheetlist); + $reader->load($file); + } + + #[DataProvider('providerIdentify')] + public function testCreateSheetIfNoSheet(string $file, string $expectedName, string $expectedClass): void + { + $actual = IOFactory::identify($file); + self::assertSame($expectedName, $actual); + $reader = IOFactory::createReaderForFile($file); + self::assertSame($expectedClass, $reader::class); + $reader->setCreateBlankSheetIfNoneRead(true); + $sheetlist = ['Unknown sheetname']; + $reader->setLoadSheetsOnly($sheetlist); + $spreadsheet = $reader->load($file); + $sheet = $spreadsheet->getActiveSheet(); + self::assertSame('Worksheet', $sheet->getTitle()); + self::assertCount(1, $spreadsheet->getAllSheets()); + $spreadsheet->disconnectWorksheets(); + } + + public static function providerIdentify(): array + { + return [ + ['samples/templates/26template.xlsx', 'Xlsx', Reader\Xlsx::class], + ['samples/templates/GnumericTest.gnumeric', 'Gnumeric', Reader\Gnumeric::class], + ['samples/templates/30template.xls', 'Xls', Reader\Xls::class], + ['samples/templates/OOCalcTest.ods', 'Ods', Reader\Ods::class], + ['samples/templates/excel2003.xml', 'Xml', Reader\Xml::class], + ]; + } +}