Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions COMPAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ clear "not yet supported" exception. Phase numbers refer to PLAN.md §13.
| Coordinate | `columnIndexFromString`, `stringFromColumnIndex`, `coordinateFromString`, `indexesFromString`, `rangeBoundaries`, `rangeDimension`, `splitRange` | pure PHP port |
| DataType | all `TYPE_*` constants | |
| Shared\Date | `PHPToExcel`, `dateTimeToExcel`, `timestampToExcel`, `stringToExcel`, `excelToDateTimeObject`, `excelToTimestamp`, `formattedPHPToExcel`, 1900/1904 calendars | Julian-day algorithm ported verbatim, incl. the 1900 leap-year bug |
| IOFactory | `createWriter/Reader` (Xlsx, Csv, Html), `load`, `identify` | |
| IOFactory | `createWriter/Reader` (Xlsx, Csv, Html), `load`, `identify`, `createReaderForFile`, `registerWriter`, `registerReader` | registered writers/readers are consulted before the built-ins, so a format can be overridden (e.g. 'Html') or added (e.g. 'Pdf'); `createReaderForFile` identifies by extension first, then probes `canRead()` — registered readers before built-ins, Csv last (its `canRead` is a catch-all). Invalid registrations throw `Writer\Exception` / `Reader\Exception` (both aliased) |
| Writer\IWriter, Writer\BaseWriter | full PhpSpreadsheet contract (`SAVE_WITH_CHARTS`/`DISABLE_PRECALCULATE_FORMULAE`, include-charts / pre-calculate / disk-caching accessors, `openFileHandle`/`processFlags`/`maybeCloseFileHandle`) | extend `BaseWriter` (or implement `IWriter`) for custom writers; the built-in writers extend it. Chart/precalc/disk-cache flags are state-only — the extension does not consume them |
| Writer\Xlsx | `save` (paths, `php://` streams, and open resources) | |
| Writer\Csv | `set/getDelimiter`, `setEnclosure` (only `"`), `set/getLineEnding`, `set/getUseBOM`, `set/getSheetIndex`, `save` (paths, `php://` streams, open resources) | plus `setSanitizeFormulas()` (easy-excel extra, opt-in OWASP guard) |
| Writer\Html | `save`, `generateHtmlAll`, `generateHTMLHeader`, `generateStyles`, `generateNavigation`, `generateSheetData`, `generateHTMLFooter`, `set/getSheetIndex`, `writeAllSheets`, `set/getGenerateSheetNavigationBlock`, `set/getUseInlineCss`, `set/getEmbedImages`, `set/getImagesRoot`, `set/getLineEnding`, `getOrientation`, `setEditHtmlCallback`, plus the table/conditional/boolean knobs | **pure PHP** (works with or without the extension); renders formatted cell values into sheet tables with merged-cell row/colspans. Fine-grained per-cell styling and image embedding are not rendered — a single shared stylesheet is emitted |
| Reader\Xlsx | `load`, `setReadDataOnly`, `canRead` | |
| Reader\Csv | `load`, `setDelimiter`, `setEnclosure`, `setSheetIndex`, `canRead` | streams in 1k-row chunks |
| Reader\IReader | `canRead`, `load`, `LOAD_WITH_CHARTS`/`READ_DATA_ONLY`/`IGNORE_EMPTY_CELLS` constants | implement it for custom readers supplied via `IOFactory::registerReader`; the built-in readers implement it |
| Reader\Xlsx | `load`, `setReadDataOnly`, `canRead` | implements `IReader` |
| Reader\Csv | `load`, `setDelimiter`, `setEnclosure`, `setSheetIndex`, `canRead` | implements `IReader`; streams in 1k-row chunks |
| Value binding | DefaultValueBinder semantics: numeric strings → numbers (leading-zero strings preserved), `=…` → formula, `DateTimeInterface` → Excel serial | |

## Supported (Phase 2 — formatting & structure)
Expand Down
6 changes: 6 additions & 0 deletions MISSING.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ GD `MemoryDrawing`, the PhpSpreadsheet `Chart\*` object model
(`getAutoFilter()->getColumn()`). This completes Phase 4 — MISSING.md now
lists only items that stay out by design.

Closed by wave 4.5 (2026-07-06): custom writer/reader registration
(`IOFactory::registerWriter/registerReader`, consulted before the built-ins
so formats can be overridden or added), `IOFactory::createReaderForFile`
with `canRead()` probing, the `Reader\IReader` contract (implemented by the
built-in readers), and the `Writer\Exception` / `Reader\Exception` classes.

Closed by wave 4.3 (2026-06-13): insert/remove rows and columns,
`createSheet($index)`, sheet copy (`Spreadsheet::copySheet` extra), sheet
views (gridlines/zoom/RTL/tab color), headers/footers, page margins — plus
Expand Down
89 changes: 87 additions & 2 deletions php/src/EasyExcel/Compat/IOFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
namespace EasyExcel\Compat;

use EasyExcel\Compat\Reader\Csv as CsvReader;
use EasyExcel\Compat\Reader\Exception as ReaderException;
use EasyExcel\Compat\Reader\IReader;
use EasyExcel\Compat\Reader\Xlsx as XlsxReader;
use EasyExcel\Compat\Writer\Csv as CsvWriter;
use EasyExcel\Compat\Writer\Exception as WriterException;
use EasyExcel\Compat\Writer\Html as HtmlWriter;
use EasyExcel\Compat\Writer\IWriter;
use EasyExcel\Compat\Writer\Xlsx as XlsxWriter;
Expand All @@ -19,8 +22,43 @@ abstract class IOFactory
public const WRITER_CSV = 'Csv';
public const WRITER_HTML = 'Html';

/**
* User-registered writers, consulted before the built-ins so a format
* (e.g. 'Html') can be overridden and new ones (e.g. 'Pdf') added.
*
* @var array<string, class-string<IWriter>>
*/
private static array $registeredWriters = [];

/** @var array<string, class-string<IReader>> */
private static array $registeredReaders = [];

/** @param class-string<IWriter> $writerClass */
public static function registerWriter(string $writerType, string $writerClass): void
{
if (!\is_a($writerClass, IWriter::class, true)) {
throw new WriterException('Registered writers must implement ' . IWriter::class);
}
self::$registeredWriters[$writerType] = $writerClass;
}

/** @param class-string<IReader> $readerClass */
public static function registerReader(string $readerType, string $readerClass): void
{
if (!\is_a($readerClass, IReader::class, true)) {
throw new ReaderException('Registered readers must implement ' . IReader::class);
}
self::$registeredReaders[$readerType] = $readerClass;
}

public static function createWriter(Spreadsheet $spreadsheet, string $writerType): IWriter
{
if (isset(self::$registeredWriters[$writerType])) {
$writerClass = self::$registeredWriters[$writerType];

return new $writerClass($spreadsheet);
}

return match ($writerType) {
self::WRITER_XLSX => new XlsxWriter($spreadsheet),
self::WRITER_CSV => new CsvWriter($spreadsheet),
Expand All @@ -31,8 +69,14 @@ public static function createWriter(Spreadsheet $spreadsheet, string $writerType
};
}

public static function createReader(string $readerType): XlsxReader|CsvReader
public static function createReader(string $readerType): IReader
{
if (isset(self::$registeredReaders[$readerType])) {
$readerClass = self::$registeredReaders[$readerType];

return new $readerClass();
}

return match ($readerType) {
self::READER_XLSX => new XlsxReader(),
self::READER_CSV => new CsvReader(),
Expand All @@ -42,9 +86,50 @@ public static function createReader(string $readerType): XlsxReader|CsvReader
};
}

/**
* Extension-based identification first (cheap, covers the built-ins);
* unknown extensions fall back to canRead() probing so registered readers
* get a chance. Registered readers probe before built-ins, and Csv probes
* last since its canRead() accepts any readable file.
*
* @param string[]|null $readers restrict to these reader types (as in PhpSpreadsheet)
*/
public static function createReaderForFile(string $filename, ?array $readers = null): IReader
{
$probeTypes = [
...\array_keys(self::$registeredReaders),
self::READER_XLSX,
self::READER_CSV,
];
if ($readers !== null) {
$probeTypes = \array_values(\array_intersect($probeTypes, $readers));
}

try {
$type = self::identify($filename);
if ($readers === null || \in_array($type, $readers, true)) {
$reader = self::createReader($type);
if ($reader->canRead($filename)) {
return $reader;
}
}
} catch (Exception) {
// unknown extension — fall through to probing
}

foreach (\array_unique($probeTypes) as $type) {
$reader = self::createReader($type);
if ($reader->canRead($filename)) {
return $reader;
}
}

throw new ReaderException("Unable to identify a reader for this file: $filename");
}

public static function load(string $filename, int $flags = 0, ?array $readers = null): Spreadsheet
{
return self::createReader(self::identify($filename))->load($filename);
return self::createReaderForFile($filename, $readers)->load($filename, $flags);
}

public static function identify(string $filename, ?array $readers = null): string
Expand Down
2 changes: 1 addition & 1 deletion php/src/EasyExcel/Compat/Reader/Csv.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use EasyExcel\Compat\Exception;
use EasyExcel\Compat\Spreadsheet;

class Csv
class Csv implements IReader
{
private string $delimiter = ',';
private string $enclosure = '"';
Expand Down
10 changes: 10 additions & 0 deletions php/src/EasyExcel/Compat/Reader/Exception.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace EasyExcel\Compat\Reader;

/** Aliased to PhpOffice\PhpSpreadsheet\Reader\Exception by the bootstrap. */
class Exception extends \EasyExcel\Compat\Exception
{
}
32 changes: 32 additions & 0 deletions php/src/EasyExcel/Compat/Reader/IReader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace EasyExcel\Compat\Reader;

use EasyExcel\Compat\Spreadsheet;

/**
* Reader contract, PhpSpreadsheet-compatible. Lets user code type-hint against
* the reader surface and supply custom readers via IOFactory::registerReader().
* The built-in Csv/Xlsx readers implement it.
*/
interface IReader
{
/** Load charts along with the data (passed to {@see load()} via $flags). */
public const LOAD_WITH_CHARTS = 1;

/** Load cell values only, skipping formatting (passed to {@see load()} via $flags). */
public const READ_DATA_ONLY = 2;

/** Skip empty cells while loading (passed to {@see load()} via $flags). */
public const IGNORE_EMPTY_CELLS = 4;

/** Can this reader read the given file? */
public function canRead(string $filename): bool;

/**
* @param int $flags bitmask of self::LOAD_WITH_CHARTS / self::READ_DATA_ONLY / self::IGNORE_EMPTY_CELLS
*/
public function load(string $filename, int $flags = 0): Spreadsheet;
}
2 changes: 1 addition & 1 deletion php/src/EasyExcel/Compat/Reader/Xlsx.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use EasyExcel\Compat\Spreadsheet;
use EasyExcel\Native;

class Xlsx
class Xlsx implements IReader
{
private bool $readDataOnly = false;

Expand Down
40 changes: 40 additions & 0 deletions php/src/EasyExcel/Compat/Spreadsheet.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace EasyExcel\Compat;

use EasyExcel\Compat\Cell\IValueBinder;
use EasyExcel\Compat\Worksheet\Worksheet;
use EasyExcel\Native;

Expand Down Expand Up @@ -84,6 +85,30 @@ public function setActiveSheetIndexByName(string $worksheetName): Worksheet
throw new Exception("Workbook does not contain sheet: $worksheetName");
}

/**
* PhpSpreadsheet parity: attach an externally constructed (detached)
* Worksheet. The sheet is created natively under its current title and
* inserted at $sheetIndex (appended when null/out of range).
*/
public function addSheet(Worksheet $worksheet, ?int $sheetIndex = null): Worksheet
{
if ($this->getSheetByName($worksheet->getTitle()) !== null) {
throw new Exception(
'Workbook already contains a worksheet named "' . $worksheet->getTitle() . '"; rename it first'
);
}
$worksheet->rebindParent($this);
Native::addSheet($this->getHandle(), $worksheet->getTitle());
if ($sheetIndex === null || $sheetIndex >= \count($this->worksheets)) {
$this->worksheets[] = $worksheet;
} else {
Native::moveSheet($this->getHandle(), $worksheet->getTitle(), $sheetIndex);
\array_splice($this->worksheets, $sheetIndex, 0, [$worksheet]);
}

return $worksheet;
}

public function createSheet(?int $sheetIndex = null): Worksheet
{
$name = $this->nextSheetName();
Expand Down Expand Up @@ -166,6 +191,21 @@ public function removeSheetByIndex(int $sheetIndex): void
\array_splice($this->worksheets, $sheetIndex, 1);
}

/** Workbook-level value binder (PhpSpreadsheet >= 2.x); overrides the legacy static Cell binder. */
private ?IValueBinder $valueBinder = null;

public function setValueBinder(?IValueBinder $valueBinder): static
{
$this->valueBinder = $valueBinder;

return $this;
}

public function getValueBinder(): ?IValueBinder
{
return $this->valueBinder;
}

private ?Document\Properties $properties = null;

public function getProperties(): Document\Properties
Expand Down
Loading
Loading