Repository: PHPOffice/PhpSpreadsheet (php)

Issue:
IOFactory::identify() returns wrong value when there's a custom reader registered
When I create a custom reader class, say EchXlsxReader, and register it with registerReader() method, identify method behaves incorrect, returning the class name instead of the type for which it was registered. According to official docs, https://phpspreadsheet.readthedocs.io/en/latest/topics/reading-files/, the following code should work:
```
/**  Identify the type of $inputFileName  **/for 
$inputFileType = \PhpOffice\PhpSpreadsheet\IOFactory::identify($inputFileName);
/**  Create a new Reader of the type that has been identified  **/
$reader = \PhpOffice\PhpSpreadsheet\IOFactory::createReader($inputFileType);
/**  Load $inputFileName to a Spreadsheet Object  **/
$spreadsheet = $reader->load($inputFileName);
```
But that really doesn't work:
```
// I register a custom reader to use for XLSX files innstead of standard one.
IOFactory::registerReader(IOFactory::READER_XLSX, EchXlsxReader::class);
...
// The following code will work fine:
$objPHPExcel = IOFactory::load($inputFileName);

// But the code from the mentioned sample fails:
$inputFileType = IOFactory::identify($inputFileName); //Successfully creates the reader and returns "EchXlsxReader"
$objReader = IOFactory::createReader($inputFileType); //Throws error, "No reader found for type EchXlsxReader"
```
According to the docs the behavior is incorrect, it works for built-in readers just because their (non-qualified) names are equal to the names of the types they read.

Pull request description:
IOFactory::identify and Custom Reader/Writer
Fix #4357.`identify` returns a type, not a class name, and this result is not always usable for createReader. The docs say that it is usable in that manner. Changing the behavior of `identify` would be a breaking change, but adding an optional parameter (defaulting to current behavior) allowing it to return a class name rather than a type would not. This PR adds that parameter and updates the documentation to suggest its use for the code in question.

To complete this change, `createReader` now accepts either a type (current behavior) or class name (new). Although it is not particularly identified as a possible problem by the issue, `createWriter` is similarly changed for consistency's sake.

This is:

- [x] a bugfix
- [ ] a new feature
- [ ] refactoring
- [ ] additional unit tests

Checklist:

- [x] Changes are covered by unit tests
  - [x] Changes are covered by existing unit tests
  - [x] New unit tests have been added
- [x] Code style is respected
- [x] Commit message explains **why** the change is made (see https://github.com/erlang/otp/wiki/Writing-good-commit-messages)
- [ ] CHANGELOG.md contains a short summary of the change and a link to the pull request if applicable
- [x] Documentation is updated as necessary


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8d60ca306..5957fac33 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org).
 ### Changed
 
 - Start migration to Phpstan 2. [PR #4359](https://github.com/PHPOffice/PhpSpreadsheet/pull/4359)
+- IOFactory identify can return, and createReader and CreateWriter can accept, a class name rather than a file type. [Issue #4357](https://github.com/PHPOffice/PhpSpreadsheet/issues/4357) [PR #4361](https://github.com/PHPOffice/PhpSpreadsheet/pull/4361)
 
 ### Moved
 
diff --git a/docs/topics/reading-files.md b/docs/topics/reading-files.md
index 4aba019ff..9b80f5b0d 100644
--- a/docs/topics/reading-files.md
+++ b/docs/topics/reading-files.md
@@ -123,7 +123,10 @@ method to identify the reader that you need, before using the
 ```php
 $inputFileName = './sampleData/example1.xls';
 
-/**  Identify the type of $inputFileName  **/
+/**
+ * Identify the type of $inputFileName.
+ * See below for a possible improvement for release 4.1.0+.
+ */
 $inputFileType = \PhpOffice\PhpSpreadsheet\IOFactory::identify($inputFileName);
 /**  Create a new Reader of the type that has been identified  **/
 $reader = \PhpOffice\PhpSpreadsheet\IOFactory::createReader($inputFileType);
@@ -134,6 +137,13 @@ $spreadsheet = $reader->load($inputFileName);
 See `samples/Reader/04_Simple_file_reader_using_the_IOFactory_to_identify_a_reader_to_use.php`
 for a working example of this code.
 
+Prior to release 4.1.0, `identify` returns a file type.
+It may be more useful to return a fully-qualified class name,
+which can be accomplished using a parameter introduced in 4.1.0:
+```php
+$inputFileType = \PhpOffice\PhpSpreadsheet\IOFactory::identify($inputFileName, null, true);
+```
+
 As with the IOFactory `load()` method, you can also pass an array of formats
 for  the `identify()` method to check against if you know that it will only
 be in a subset of the possible formats that PhpSpreadsheet supports.
diff --git a/src/PhpSpreadsheet/IOFactory.php b/src/PhpSpreadsheet/IOFactory.php
index db8776db6..73734fc70 100644
--- a/src/PhpSpreadsheet/IOFactory.php
+++ b/src/PhpSpreadsheet/IOFactory.php
@@ -59,12 +59,16 @@ abstract class IOFactory
      */
     public static function createWriter(Spreadsheet $spreadsheet, string $writerType): IWriter
     {
-        if (!isset(self::$writers[$writerType])) {
-            throw new Writer\Exception("No writer found for type $writerType");
-        }
+        /** @var class-string<IWriter> */
+        $className = $writerType;
+        if (!in_array($writerType, self::$writers, true)) {
+            if (!isset(self::$writers[$writerType])) {
+                throw new Writer\Exception("No writer found for type $writerType");
+            }
 
-        // Instantiate writer
-        $className = self::$writers[$writerType];
+            // Instantiate writer
+            $className = self::$writers[$writerType];
+        }
 
         return new $className($spreadsheet);
     }
@@ -74,12 +78,16 @@ abstract class IOFactory
      */
     public static function createReader(string $readerType): IReader
     {
-        if (!isset(self::$readers[$readerType])) {
-            throw new Reader\Exception("No reader found for type $readerType");
-        }
+        /** @var class-string<IReader> */
+        $className = $readerType;
+        if (!in_array($readerType, self::$readers, true)) {
+            if (!isset(self::$readers[$readerType])) {
+                throw new Reader\Exception("No reader found for type $readerType");
+            }
 
-        // Instantiate reader
-        $className = self::$readers[$readerType];
+            // Instantiate reader
+            $className = self::$readers[$readerType];
+        }
 
         return new $className();
     }
@@ -109,12 +117,14 @@ abstract class IOFactory
     /**
      * Identify file type using automatic IReader resolution.
      */
-    public static function identify(string $filename, ?array $readers = null): string
+    public static function identify(string $filename, ?array $readers = null, bool $fullClassName = false): string
     {
         $reader = self::createReaderForFile($filename, $readers);
         $className = $reader::class;
+        if ($fullClassName) {
+            return $className;
+        }
         $classType = explode('\\', $className);
-        unset($reader);
 
         return array_pop($classType);
     }
@@ -224,6 +234,8 @@ abstract class IOFactory
 
     /**
      * Register a reader with its type and class name.
+     *
+     * @param class-string<IReader> $readerClass
      */
     public static function registerReader(string $readerType, string $readerClass): void
     {


Test patch:
diff --git a/tests/PhpSpreadsheetTests/CustomReader.php b/tests/PhpSpreadsheetTests/CustomReader.php
new file mode 100644
index 000000000..9d48ae8ef
--- /dev/null
+++ b/tests/PhpSpreadsheetTests/CustomReader.php
@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+namespace PhpOffice\PhpSpreadsheetTests;
+
+use PhpOffice\PhpSpreadsheet\Reader\Xlsx as XlsxReader;
+
+// Used in IOFactoryRegister tests
+class CustomReader extends XlsxReader
+{
+}
diff --git a/tests/PhpSpreadsheetTests/CustomWriter.php b/tests/PhpSpreadsheetTests/CustomWriter.php
new file mode 100644
index 000000000..abda4bf62
--- /dev/null
+++ b/tests/PhpSpreadsheetTests/CustomWriter.php
@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+namespace PhpOffice\PhpSpreadsheetTests;
+
+use PhpOffice\PhpSpreadsheet\Writer\Html as HtmlWriter;
+
+// Used in IOFactoryRegister tests
+class CustomWriter extends HtmlWriter
+{
+}
diff --git a/tests/PhpSpreadsheetTests/IOFactoryRegisterTest.php b/tests/PhpSpreadsheetTests/IOFactoryRegisterTest.php
new file mode 100644
index 000000000..246fdc420
--- /dev/null
+++ b/tests/PhpSpreadsheetTests/IOFactoryRegisterTest.php
@@ -0,0 +1,87 @@
+<?php
+
+declare(strict_types=1);
+
+namespace PhpOffice\PhpSpreadsheetTests;
+
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use PhpOffice\PhpSpreadsheet\Reader;
+use PhpOffice\PhpSpreadsheet\Reader\Exception as ReaderException;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Writer;
+use PHPUnit\Framework\Attributes;
+use PHPUnit\Framework\TestCase;
+
+// Separate processes because register arrays are static
+#[Attributes\RunTestsInSeparateProcesses]
+class IOFactoryRegisterTest extends TestCase
+{
+    public function testRegisterWriter(): void
+    {
+        IOFactory::registerWriter('Pdf', Writer\Pdf\Mpdf::class);
+        $spreadsheet = new Spreadsheet();
+        $actual = IOFactory::createWriter($spreadsheet, 'Pdf');
+        self::assertInstanceOf(Writer\Pdf\Mpdf::class, $actual);
+    }
+
+    public function testRegisterReader(): void
+    {
+        IOFactory::registerReader('Custom', Reader\Html::class);
+        $actual = IOFactory::createReader('Custom');
+        self::assertInstanceOf(Reader\Html::class, $actual);
+    }
+
+    public function testRegisterInvalidWriter(): void
+    {
+        $this->expectException(Writer\Exception::class);
+        $this->expectExceptionMessage('writers must implement');
+        IOFactory::registerWriter('foo', 'bar'); // @phpstan-ignore-line
+    }
+
+    public function testRegisterInvalidReader(): void
+    {
+        $this->expectException(ReaderException::class);
+        $this->expectExceptionMessage('readers must implement');
+        IOFactory::registerReader('foo', 'bar'); // @phpstan-ignore-line
+    }
+
+    public static function testRegisterCustomReader(): void
+    {
+        IOFactory::registerReader(IOFactory::READER_XLSX, CustomReader::class);
+        $inputFileName = 'tests/data/Reader/XLSX/1900_Calendar.xlsx';
+        $loadSpreadsheet = IOFactory::load($inputFileName);
+        $sheet = $loadSpreadsheet->getActiveSheet();
+        self::assertSame('2022-01-01', $sheet->getCell('A1')->getFormattedValue());
+        $loadSpreadsheet->disconnectWorksheets();
+
+        $reader = new CustomReader();
+        $newSpreadsheet = $reader->load($inputFileName);
+        $newSheet = $newSpreadsheet->getActiveSheet();
+        self::assertSame('2022-01-01', $newSheet->getCell('A1')->getFormattedValue());
+        $newSpreadsheet->disconnectWorksheets();
+
+        $inputFileType = IOFactory::identify($inputFileName, null, true);
+        $objReader = IOFactory::createReader($inputFileType);
+        self::assertInstanceOf(CustomReader::class, $objReader);
+        $objSpreadsheet = $objReader->load($inputFileName);
+        $objSheet = $objSpreadsheet->getActiveSheet();
+        self::assertSame('2022-01-01', $objSheet->getCell('A1')->getFormattedValue());
+        $objSpreadsheet->disconnectWorksheets();
+    }
+
+    public static function testRegisterCustomWriter(): void
+    {
+        $spreadsheet = new Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->setCellValue('A1', 1);
+        $writer = new CustomWriter($spreadsheet);
+        $html = $writer->generateHtmlAll();
+        self::assertStringContainsString('<td class="column0 style0 n">1</td>', $html);
+        IOFactory::registerWriter(IOFactory::WRITER_HTML, CustomWriter::class);
+        $objWriter = IOFactory::createWriter($spreadsheet, CustomWriter::class);
+        self::assertInstanceOf(CustomWriter::class, $objWriter);
+        $html2 = $objWriter->generateHtmlAll();
+        self::assertStringContainsString('<td class="column0 style0 n">1</td>', $html2);
+        $spreadsheet->disconnectWorksheets();
+    }
+}
diff --git a/tests/PhpSpreadsheetTests/IOFactoryTest.php b/tests/PhpSpreadsheetTests/IOFactoryTest.php
index 7fb1d79e9..bb56a158d 100644
--- a/tests/PhpSpreadsheetTests/IOFactoryTest.php
+++ b/tests/PhpSpreadsheetTests/IOFactoryTest.php
@@ -159,21 +159,6 @@ class IOFactoryTest extends TestCase
         IOFactory::identify('.');
     }
 
-    public function testRegisterInvalidWriter(): void
-    {
-        $this->expectException(Writer\Exception::class);
-
-        // @phpstan-ignore-next-line
-        IOFactory::registerWriter('foo', 'bar');
-    }
-
-    public function testRegisterInvalidReader(): void
-    {
-        $this->expectException(ReaderException::class);
-
-        IOFactory::registerReader('foo', 'bar');
-    }
-
     public function testCreateInvalidWriter(): void
     {
         $this->expectException(Writer\Exception::class);