diff --git a/COMPAT.md b/COMPAT.md index 3ccd280..6130dde 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -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) diff --git a/MISSING.md b/MISSING.md index 199bcff..783ea94 100644 --- a/MISSING.md +++ b/MISSING.md @@ -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 diff --git a/php/src/EasyExcel/Compat/IOFactory.php b/php/src/EasyExcel/Compat/IOFactory.php index 6a6abb6..57c32fe 100644 --- a/php/src/EasyExcel/Compat/IOFactory.php +++ b/php/src/EasyExcel/Compat/IOFactory.php @@ -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; @@ -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> + */ + private static array $registeredWriters = []; + + /** @var array> */ + private static array $registeredReaders = []; + + /** @param class-string $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 $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), @@ -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(), @@ -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 diff --git a/php/src/EasyExcel/Compat/Reader/Csv.php b/php/src/EasyExcel/Compat/Reader/Csv.php index 1d4cb9a..da216d6 100644 --- a/php/src/EasyExcel/Compat/Reader/Csv.php +++ b/php/src/EasyExcel/Compat/Reader/Csv.php @@ -7,7 +7,7 @@ use EasyExcel\Compat\Exception; use EasyExcel\Compat\Spreadsheet; -class Csv +class Csv implements IReader { private string $delimiter = ','; private string $enclosure = '"'; diff --git a/php/src/EasyExcel/Compat/Reader/Exception.php b/php/src/EasyExcel/Compat/Reader/Exception.php new file mode 100644 index 0000000..9958172 --- /dev/null +++ b/php/src/EasyExcel/Compat/Reader/Exception.php @@ -0,0 +1,10 @@ +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(); @@ -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 diff --git a/php/src/EasyExcel/Compat/Worksheet/Worksheet.php b/php/src/EasyExcel/Compat/Worksheet/Worksheet.php index e5e77f3..e2af1d7 100644 --- a/php/src/EasyExcel/Compat/Worksheet/Worksheet.php +++ b/php/src/EasyExcel/Compat/Worksheet/Worksheet.php @@ -8,6 +8,7 @@ use EasyExcel\Compat\Cell\Coordinate; use EasyExcel\Compat\Cell\DataType; use EasyExcel\Compat\Cell\Hyperlink; +use EasyExcel\Compat\Cell\IValueBinder; use EasyExcel\Compat\Comment; use EasyExcel\Compat\RichText\RichText; use EasyExcel\Compat\Exception; @@ -43,15 +44,48 @@ class Worksheet /** @var list List of attached drawings */ private array $drawings = []; - public function __construct(private Spreadsheet $parent, private string $title) + /** + * PhpSpreadsheet parity: a Worksheet may be constructed detached + * (`new Worksheet()`) and attached later via Spreadsheet::addSheet(). + * Until attached, only title get/set work; anything touching the native + * workbook throws (see workbookHandle()). + */ + public function __construct(private ?Spreadsheet $parent = null, private string $title = 'Worksheet') { } - public function getParent(): Spreadsheet + public function getParent(): ?Spreadsheet { return $this->parent; } + /** @internal called by Spreadsheet::addSheet() when attaching a detached sheet */ + public function rebindParent(Spreadsheet $parent): static + { + if ($this->parent === $parent) { + return $this; + } + if ($this->parent !== null) { + throw new Exception( + 'Worksheet "' . $this->title . '" already belongs to a workbook; moving sheets between workbooks is not supported' + ); + } + $this->parent = $parent; + + return $this; + } + + private function workbookHandle(): int + { + if ($this->parent === null) { + throw new Exception( + 'Worksheet "' . $this->title . '" is not attached to a Spreadsheet yet — add it with $spreadsheet->addSheet() first' + ); + } + + return $this->parent->getHandle(); + } + public function getTitle(): string { return $this->title; @@ -62,7 +96,9 @@ public function setTitle(string $title, bool $updateFormulaCellReferences = true if ($title === $this->title) { return $this; } - Native::renameSheet($this->parent->getHandle(), $this->title, $title); + if ($this->parent !== null) { + Native::renameSheet($this->parent->getHandle(), $this->title, $title); + } $this->title = $title; return $this; @@ -91,7 +127,7 @@ public function setCellValue(string|array $coordinate, mixed $value): static return $this; } - if (($binder = Cell::customValueBinder()) !== null) { + if (($binder = $this->activeBinder()) !== null) { [$col, $row] = $this->toIndexes($coordinate); $binder->bindValue(new Cell($this, Coordinate::stringFromColumnIndex($col) . $row), $value); @@ -103,6 +139,16 @@ public function setCellValue(string|array $coordinate, mixed $value): static return $this; } + /** + * Effective custom binder: the workbook-level binder + * (Spreadsheet::setValueBinder(), PhpSpreadsheet >= 2.x) wins over the + * legacy process-wide Cell::setValueBinder(); null means default binding. + */ + private function activeBinder(): ?IValueBinder + { + return $this->parent?->getValueBinder() ?? Cell::customValueBinder(); + } + /** @internal rich-text cell value (wave 4.4): queued via the extension */ public function setRichTextValue(string $coordinate, RichText $richText): void { @@ -111,7 +157,7 @@ public function setRichTextValue(string $coordinate, RichText $richText): void [$col, $row] = Coordinate::indexesFromString($coordinate); $this->bufferCell($row, $col, $richText->getPlainText()); $this->flush(); - Native::setRichText($this->parent->getHandle(), $this->title, $coordinate, $richText->toRunSpecs()); + Native::setRichText($this->workbookHandle(), $this->title, $coordinate, $richText->toRunSpecs()); } public function setCellValueByColumnAndRow(int $columnIndex, int $row, mixed $value): static @@ -151,7 +197,7 @@ public function fromArray(array $source, mixed $nullValue = null, string $startC // a custom binder must see every value: route through the buffered // per-cell path (slower, still batched per 512 rows) - if (($binder = Cell::customValueBinder()) !== null) { + if (($binder = $this->activeBinder()) !== null) { $row = $startRow; foreach ($source as $rowData) { $col = $startCol; @@ -181,13 +227,13 @@ public function fromArray(array $source, mixed $nullValue = null, string $startC $chunk[] = $encoded; ++$row; if (\count($chunk) >= 1024) { - Native::writeRows($this->parent->getHandle(), $this->title, $chunkStart, $startCol, $chunk); + Native::writeRows($this->workbookHandle(), $this->title, $chunkStart, $startCol, $chunk); $chunk = []; $chunkStart = $row; } } if ($chunk !== []) { - Native::writeRows($this->parent->getHandle(), $this->title, $chunkStart, $startCol, $chunk); + Native::writeRows($this->workbookHandle(), $this->title, $chunkStart, $startCol, $chunk); } return $this; @@ -218,13 +264,13 @@ public function readCell(string $coordinate, int $mode): mixed } } - return Native::getCell($this->parent->getHandle(), $this->title, $coordinate, $mode); + return Native::getCell($this->workbookHandle(), $this->title, $coordinate, $mode); } public function toArray(mixed $nullValue = null, bool $calculateFormulas = true, bool $formatData = true, bool $returnCellRef = false): array { $this->flush(); - [$maxRow, $maxCol] = Native::dimensions($this->parent->getHandle(), $this->title); + [$maxRow, $maxCol] = Native::dimensions($this->workbookHandle(), $this->title); return $this->collectRows(1, \max($maxRow, 1), 1, \max($maxCol, 1), $nullValue, $formatData, $returnCellRef, $calculateFormulas); } @@ -243,7 +289,7 @@ public function rangeToArray(string $range, mixed $nullValue = null, bool $calcu */ private function collectRows(int $startRow, int $endRow, int $startCol, ?int $endCol, mixed $nullValue, bool $formatData, bool $returnCellRef, bool $calc = false): array { - $handle = $this->parent->getHandle(); + $handle = $this->workbookHandle(); $filter = $this->parent->getReadFilter(); $out = []; $row = $startRow; @@ -305,7 +351,7 @@ private function collectRows(int $startRow, int $endRow, int $startCol, ?int $en public function getHighestRow(?string $column = null): int { $this->flush(); - [$maxRow] = Native::dimensions($this->parent->getHandle(), $this->title); + [$maxRow] = Native::dimensions($this->workbookHandle(), $this->title); return \max($maxRow, 1); } @@ -313,7 +359,7 @@ public function getHighestRow(?string $column = null): int public function getHighestColumn(?int $row = null): string { $this->flush(); - [, $maxCol] = Native::dimensions($this->parent->getHandle(), $this->title); + [, $maxCol] = Native::dimensions($this->workbookHandle(), $this->title); return Coordinate::stringFromColumnIndex(\max($maxCol, 1)); } @@ -340,7 +386,7 @@ public function mergeCells(string|array $range): static } // no flush: the Go op-log applies merges by coordinates regardless of // when the rows arrive, and flushing here would end streaming early - Native::mergeCells($this->parent->getHandle(), $this->title, $range); + Native::mergeCells($this->workbookHandle(), $this->title, $range); return $this; } @@ -353,7 +399,7 @@ public function unmergeCells(string|array $range): static $range )); } - Native::unmergeCells($this->parent->getHandle(), $this->title, $range); + Native::unmergeCells($this->workbookHandle(), $this->title, $range); return $this; } @@ -363,7 +409,7 @@ public function getMergeCells(): array { $this->flush(); $out = []; - foreach (Native::getMerges($this->parent->getHandle(), $this->title) as $range) { + foreach (Native::getMerges($this->workbookHandle(), $this->title) as $range) { $out[$range] = $range; } @@ -422,7 +468,7 @@ public function setAutoFilter(string|array $range): static )); } $this->autoFilterRange = $range; - Native::autoFilter($this->parent->getHandle(), $this->title, $range); + Native::autoFilter($this->workbookHandle(), $this->title, $range); return $this; } @@ -457,7 +503,7 @@ public function duplicateStyle(Style $style, string $range): static { $spec = $style->describe(); if ($spec !== []) { - Native::applyStyle($this->parent->getHandle(), $this->title, $range, $spec); + Native::applyStyle($this->workbookHandle(), $this->title, $range, $spec); } return $this; @@ -465,7 +511,7 @@ public function duplicateStyle(Style $style, string $range): static public function freezePane(?string $coordinate): static { - Native::freezePanes($this->parent->getHandle(), $this->title, $coordinate ?? ''); + Native::freezePanes($this->workbookHandle(), $this->title, $coordinate ?? ''); return $this; } @@ -497,7 +543,7 @@ public function getCommentByColumnAndRow(int $columnIndex, int $row): Comment public function setHyperlink(string $cellCoordinate, ?Hyperlink $hyperlink): static { Native::setHyperlink( - $this->parent->getHandle(), + $this->workbookHandle(), $this->title, $cellCoordinate, $hyperlink?->getUrl() ?? '', @@ -522,7 +568,7 @@ public function getProtection(): Protection public function insertNewRowBefore(int $before, int $count = 1): static { $this->flush(); - Native::insertRows($this->parent->getHandle(), $this->title, $before, $count); + Native::insertRows($this->workbookHandle(), $this->title, $before, $count); return $this; } @@ -530,7 +576,7 @@ public function insertNewRowBefore(int $before, int $count = 1): static public function removeRow(int $row, int $count = 1): static { $this->flush(); - Native::removeRows($this->parent->getHandle(), $this->title, $row, $count); + Native::removeRows($this->workbookHandle(), $this->title, $row, $count); return $this; } @@ -538,7 +584,7 @@ public function removeRow(int $row, int $count = 1): static public function insertNewColumnBefore(string $before, int $count = 1): static { $this->flush(); - Native::insertCols($this->parent->getHandle(), $this->title, Coordinate::columnIndexFromString($before), $count); + Native::insertCols($this->workbookHandle(), $this->title, Coordinate::columnIndexFromString($before), $count); return $this; } @@ -546,7 +592,7 @@ public function insertNewColumnBefore(string $before, int $count = 1): static public function insertNewColumnBeforeByIndex(int $beforeColumnIndex, int $count = 1): static { $this->flush(); - Native::insertCols($this->parent->getHandle(), $this->title, $beforeColumnIndex, $count); + Native::insertCols($this->workbookHandle(), $this->title, $beforeColumnIndex, $count); return $this; } @@ -554,7 +600,7 @@ public function insertNewColumnBeforeByIndex(int $beforeColumnIndex, int $count public function removeColumn(string $column, int $count = 1): static { $this->flush(); - Native::removeCols($this->parent->getHandle(), $this->title, Coordinate::columnIndexFromString($column), $count); + Native::removeCols($this->workbookHandle(), $this->title, Coordinate::columnIndexFromString($column), $count); return $this; } @@ -562,7 +608,7 @@ public function removeColumn(string $column, int $count = 1): static public function removeColumnByIndex(int $columnIndex, int $count = 1): static { $this->flush(); - Native::removeCols($this->parent->getHandle(), $this->title, $columnIndex, $count); + Native::removeCols($this->workbookHandle(), $this->title, $columnIndex, $count); return $this; } @@ -610,7 +656,7 @@ public function setDataValidation(string $range, ?\EasyExcel\Compat\Cell\DataVal { if ($dataValidation !== null) { Native::setValidation( - $this->parent->getHandle(), + $this->workbookHandle(), $this->title, $range, $dataValidation->toSpec(), @@ -628,7 +674,7 @@ public function setDataValidation(string $range, ?\EasyExcel\Compat\Cell\DataVal */ public function addNativeChart(string $cell, array $spec): static { - Native::addChart($this->parent->getHandle(), $this->title, $cell, $spec); + Native::addChart($this->workbookHandle(), $this->title, $cell, $spec); return $this; } @@ -637,7 +683,7 @@ public function addNativeChart(string $cell, array $spec): static public function addChart(\EasyExcel\Compat\Chart\Chart $chart): static { Native::addChart( - $this->parent->getHandle(), + $this->workbookHandle(), $this->title, $chart->getTopLeftCell(), $chart->buildSpec(), @@ -659,7 +705,7 @@ public function flush(): void $this->bufferedRows = 0; \ksort($buffer); - $handle = $this->parent->getHandle(); + $handle = $this->workbookHandle(); $runRows = []; $runStart = null; $runMinCol = PHP_INT_MAX; @@ -716,15 +762,55 @@ private function bindValue(mixed $value): mixed return [Native::MARK_NUMERIC, Date::dateTimeToExcel($value)]; } if ($value instanceof \Stringable) { - return (string) $value; + $value = (string) $value; } if ($value !== null && !\is_scalar($value)) { throw new Exception('Unsupported cell value of type ' . \get_debug_type($value)); } + // A numeric-looking string the Go side would otherwise coerce to a + // number is forced to a text cell when that coercion would change the + // value: leading-zero strings ("0123") and integer strings past + // float64's exact range (> 2^53, e.g. 17-digit member IDs). This + // matches PhpSpreadsheet, whose Xlsx output keeps the original digits + // instead of a re-stringified float. Numeric strings that round-trip + // stay on the fast bare-scalar path. + if (\is_string($value) && $value !== '' && self::mustPreserveAsString($value)) { + return [Native::MARK_STRING, $value]; + } return $value; } + /** + * True when a numeric string must be written as text to stop the Go binder + * turning it into a different number (leading zeros, or precision loss + * beyond float64's 2^53 exact-integer range). + */ + private static function mustPreserveAsString(string $value): bool + { + if (!\is_numeric($value)) { + return false; + } + $digits = \ltrim($value, '+-'); + // leading-zero integer strings ("0123") stay text, like PhpSpreadsheet + $second = $digits[1] ?? ''; + if (\strlen($digits) > 1 && $digits[0] === '0' && $second !== '.' && $second !== 'e' && $second !== 'E') { + return true; + } + // pure integer strings beyond 2^53 (9007199254740992) lose precision + // once parsed as a float64 + if (\preg_match('/^[+-]?\d+$/', $value)) { + $normalized = \ltrim($digits, '0'); + if ($normalized !== '' + && (\strlen($normalized) > 16 + || (\strlen($normalized) === 16 && $normalized > '9007199254740992'))) { + return true; + } + } + + return false; + } + /** @return array{0: string, 1: mixed} */ private function encodeExplicit(mixed $value, string $dataType): array { diff --git a/php/src/EasyExcel/Compat/Writer/Exception.php b/php/src/EasyExcel/Compat/Writer/Exception.php new file mode 100644 index 0000000..ee16b65 --- /dev/null +++ b/php/src/EasyExcel/Compat/Writer/Exception.php @@ -0,0 +1,10 @@ +getExtension() !== 'php') { + continue; + } + $relative = \substr($file->getPathname(), \strlen($root) + 1, -4); + $suffix = \str_replace(\DIRECTORY_SEPARATOR, '\\', $relative); + $compat = 'EasyExcel\\Compat\\' . $suffix; + $target = 'PhpOffice\\PhpSpreadsheet\\' . $suffix; + if (\class_exists($target, false) || \interface_exists($target, false)) { + continue; // already aliased (or the real class won the race — leave it) + } + if (\class_exists($compat) || \interface_exists($compat)) { + \class_alias($compat, $target); + } + } +} diff --git a/php/src/bootstrap.php b/php/src/bootstrap.php index 308c93a..310f067 100644 --- a/php/src/bootstrap.php +++ b/php/src/bootstrap.php @@ -30,4 +30,9 @@ $mode = \EasyExcel\aliasMode($env, \function_exists('easy_excel_new')); \EasyExcel\registerCompatAutoloader($mode); + if ($mode !== 'off') { + // instanceof/typed-parameter checks never autoload, so lazy aliasing + // alone would miss them — alias the whole Compat surface up front. + \EasyExcel\aliasCompatSurface(); + } })(); diff --git a/php/tests/cases/io.php b/php/tests/cases/io.php index 74dd038..630bb6f 100644 --- a/php/tests/cases/io.php +++ b/php/tests/cases/io.php @@ -11,6 +11,41 @@ use EasyExcel\Compat\Writer\IWriter; use EasyExcel\Compat\Writer\Xlsx as XlsxWriter; +/** Custom writer for the registerWriter tests (the app-side 'Pdf' writer pattern). */ +final class FakePdfWriter extends BaseWriter +{ + public Spreadsheet $book; + + public function __construct(Spreadsheet $spreadsheet) + { + $this->book = $spreadsheet; + } + + public function save($filename, int $flags = 0): void + { + if (\is_string($filename)) { + \file_put_contents($filename, '%PDF-fake'); + } + } +} + +/** Custom reader for the registerReader / createReaderForFile probing tests. */ +final class FakeXmlReader implements \EasyExcel\Compat\Reader\IReader +{ + public function canRead(string $filename): bool + { + return \str_ends_with(\strtolower($filename), '.fakexml') && \is_readable($filename); + } + + public function load(string $filename, int $flags = 0): Spreadsheet + { + $s = new Spreadsheet(); + $s->getActiveSheet()->setCellValue('A1', 'from-fake-reader'); + + return $s; + } +} + return [ 'iofactory: identify and create' => function (): void { T::same('Xlsx', IOFactory::identify('/tmp/report.XLSX')); @@ -208,4 +243,67 @@ public function save($filename, int $flags = 0): void $writer = \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($s, 'Xlsx'); T::ok($writer instanceof XlsxWriter, 'IOFactory through alias'); }, + + 'iofactory: registerWriter adds and overrides formats' => function (): void { + $s = new Spreadsheet(); + + IOFactory::registerWriter('Pdf', FakePdfWriter::class); + $w = IOFactory::createWriter($s, 'Pdf'); + T::ok($w instanceof FakePdfWriter, 'registered format resolved'); + T::ok($w->book === $s, 'workbook passed to custom writer'); + + IOFactory::registerWriter('Html', FakePdfWriter::class); + try { + T::ok(IOFactory::createWriter($s, 'Html') instanceof FakePdfWriter, 'built-in format overridden'); + } finally { + IOFactory::registerWriter('Html', HtmlWriter::class); // restore for later cases + } + T::ok(IOFactory::createWriter($s, 'Html') instanceof HtmlWriter, 'override restored'); + + T::throws( + \EasyExcel\Compat\Writer\Exception::class, + static fn () => IOFactory::registerWriter('Bogus', \stdClass::class) + ); + }, + + 'iofactory: registerReader and createReaderForFile probing' => function (): void { + IOFactory::registerReader('FakeXml', FakeXmlReader::class); + T::ok(IOFactory::createReader('FakeXml') instanceof FakeXmlReader, 'registered reader resolved'); + + T::throws( + \EasyExcel\Compat\Reader\Exception::class, + static fn () => IOFactory::registerReader('Bogus', \stdClass::class) + ); + + // unknown extension: probing must reach the registered reader before Csv's catch-all + $file = \tempnam(\sys_get_temp_dir(), 'eex') . '.fakexml'; + \file_put_contents($file, ''); + try { + T::ok(IOFactory::createReaderForFile($file) instanceof FakeXmlReader, 'probed via canRead'); + $loaded = IOFactory::load($file); + T::same('from-fake-reader', $loaded->getActiveSheet()->getCell('A1')->getValue(), 'load() uses probing'); + } finally { + @\unlink($file); + } + + // known extension keeps the extension fast path + $xlsx = \tempnam(\sys_get_temp_dir(), 'eex') . '.xlsx'; + \file_put_contents($xlsx, 'stub'); + try { + T::ok( + IOFactory::createReaderForFile($xlsx) instanceof \EasyExcel\Compat\Reader\Xlsx, + 'extension-identified reader' + ); + } finally { + @\unlink($xlsx); + } + }, + + 'iofactory: readers implement IReader and new aliases resolve' => function (): void { + T::ok(new \EasyExcel\Compat\Reader\Xlsx() instanceof \EasyExcel\Compat\Reader\IReader, 'xlsx reader contract'); + T::ok(new CsvReader() instanceof \EasyExcel\Compat\Reader\IReader, 'csv reader contract'); + T::ok(\interface_exists('PhpOffice\\PhpSpreadsheet\\Reader\\IReader'), 'IReader aliased'); + T::ok(\class_exists('PhpOffice\\PhpSpreadsheet\\Writer\\Exception'), 'Writer\\Exception aliased'); + T::ok(\class_exists('PhpOffice\\PhpSpreadsheet\\Reader\\Exception'), 'Reader\\Exception aliased'); + }, ]; diff --git a/php/tests/cases/wave45.php b/php/tests/cases/wave45.php new file mode 100644 index 0000000..caf69c3 --- /dev/null +++ b/php/tests/cases/wave45.php @@ -0,0 +1,153 @@ +setValueExplicit((string) $value, DataType::TYPE_STRING); + + return true; + } + + return parent::bindValue($cell, $value); + } +} + +return [ + 'wave45: Spreadsheet::setValueBinder routes setCellValue and fromArray' => function (): void { + EasyExcelFake::reset(); + $s = new Spreadsheet(); + T::same(null, $s->getValueBinder(), 'no workbook binder by default'); + T::ok($s->setValueBinder(new AllStringBinder()) === $s, 'fluent'); + + $ws = $s->getActiveSheet(); + $ws->setCellValue('A1', 123); + $ws->fromArray([[456, 7.5]], null, 'A2', true); + $ws->flush(); + + T::same('123', $ws->getCell('A1')->getValue(), 'setCellValue routed through workbook binder'); + T::same('456', $ws->getCell('A2')->getValue(), 'fromArray routed through workbook binder'); + T::same('7.5', $ws->getCell('B2')->getValue()); + + $s->setValueBinder(null); + $ws->setCellValue('A3', 123); + $ws->flush(); + T::same(123.0, $ws->getCell('A3')->getValue(), 'null restores default binding'); + }, + + 'wave45: workbook binder wins over legacy static Cell binder' => function (): void { + EasyExcelFake::reset(); + $prop = new ReflectionProperty(Cell::class, 'valueBinder'); + $prop->setValue(null, null); + Cell::setValueBinder(new DefaultValueBinder()); // static set, default rules + try { + $s = new Spreadsheet(); + $s->setValueBinder(new AllStringBinder()); + $ws = $s->getActiveSheet(); + $ws->setCellValue('A1', 99); + $ws->flush(); + T::same('99', $ws->getCell('A1')->getValue(), 'instance binder overrides static'); + } finally { + $prop->setValue(null, null); + } + }, + + 'wave45: detached Worksheet construct, title, attach via addSheet' => function (): void { + EasyExcelFake::reset(); + $ws = new Worksheet(); + T::same('Worksheet', $ws->getTitle(), 'default title'); + T::same(null, $ws->getParent(), 'detached'); + $ws->setTitle('Region North'); + T::same('Region North', $ws->getTitle(), 'title set locally while detached'); + T::same(0, \count(EasyExcelFake::calls('rename_sheet')), 'no native rename while detached'); + + $s = new Spreadsheet(); + $returned = $s->addSheet($ws); + T::ok($returned === $ws, 'addSheet returns the sheet'); + T::ok($ws->getParent() === $s, 'attached'); + T::same(2, $s->getSheetCount()); + T::ok($s->getSheetByName('Region North') === $ws, 'find by title'); + + $ws->setCellValue('A1', 'hello'); + $ws->flush(); + T::same('hello', $ws->getCell('A1')->getValue(), 'writable after attach'); + }, + + 'wave45: addSheet honours the index and duplicate titles throw' => function (): void { + EasyExcelFake::reset(); + $s = new Spreadsheet(); + $ws = new Worksheet(null, 'First'); + $s->addSheet($ws, 0); + T::same(0, $s->getIndex($ws), 'inserted at requested index'); + T::same(1, \count(EasyExcelFake::calls('move_sheet')), 'native move issued'); + + T::throws(Exception::class, static fn () => $s->addSheet(new Worksheet(null, 'First'))); + + $other = new Spreadsheet(); + T::throws(Exception::class, static fn () => $other->addSheet($ws), 'cross-workbook rebind rejected'); + }, + + 'wave45: detached sheet operations fail loudly' => function (): void { + EasyExcelFake::reset(); + $ws = new Worksheet(null, 'Loose'); + T::throws(Exception::class, static fn () => $ws->setCellValue('A1', 1)->flush()); + }, + + 'wave45: eager aliasing covers instanceof and typed parameters' => function (): void { + // instanceof/type checks never trigger autoload, so these only pass + // when the alias exists up front (aliasCompatSurface at bootstrap). + EasyExcelFake::reset(); + $ws = (new Spreadsheet())->getActiveSheet(); + T::ok($ws instanceof \PhpOffice\PhpSpreadsheet\Worksheet\Worksheet, 'instanceof aliased name'); + $typed = static fn (\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet): string => $sheet->getTitle(); + T::same('Worksheet', $typed($ws), 'typed parameter accepts Compat object'); + }, + + 'wave45: fromArray preserves precision of big-int / leading-zero strings' => function (): void { + // Regression: the bulk path let the Go binder coerce these numeric + // strings to float64, corrupting 17-digit IDs and dropping leading + // zeros. They must round-trip as text, like PhpSpreadsheet — without + // needing a custom value binder (which forces the slow per-cell path). + EasyExcelFake::reset(); + $ws = (new Spreadsheet())->getActiveSheet(); + $ws->fromArray([[ + '9007199254740993', // 2^53 + 1: unsafe as float + '99999999999999999', // 17 digits: unsafe + '0123', // leading zero: stays text + '9007199254740992', // exactly 2^53: still safe as number + '12345', // small: stays a number + '123.45', // decimal: stays a number + ]], null, 'A1'); + $ws->flush(); + + T::same('9007199254740993', $ws->getCell('A1')->getValue(), 'big int string preserved'); + T::same('99999999999999999', $ws->getCell('B1')->getValue(), '17-digit string preserved'); + T::same('0123', $ws->getCell('C1')->getValue(), 'leading-zero string stays text'); + T::same(9007199254740992.0, $ws->getCell('D1')->getValue(), '2^53 stays numeric'); + T::same(12345.0, $ws->getCell('E1')->getValue(), 'small int stays numeric'); + T::same(123.45, $ws->getCell('F1')->getValue(), 'decimal stays numeric'); + }, + + 'wave45: setCellValue preserves big-int / leading-zero strings' => function (): void { + EasyExcelFake::reset(); + $ws = (new Spreadsheet())->getActiveSheet(); + $ws->setCellValue('A1', '99999999999999999'); + $ws->setCellValue('A2', '0123'); + $ws->setCellValue('A3', '42'); + $ws->flush(); + T::same('99999999999999999', $ws->getCell('A1')->getValue(), 'per-cell big int preserved'); + T::same('0123', $ws->getCell('A2')->getValue(), 'per-cell leading zero preserved'); + T::same(42.0, $ws->getCell('A3')->getValue(), 'per-cell small int numeric'); + }, +];