diff --git a/CHANGELOG.md b/CHANGELOG.md index 096e5c5e63..b56567736b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Added - Excel Dynamic Arrays. [Issue #3901](https://github.com/PHPOffice/PhpSpreadsheet/issues/3901) [Issue #3659](https://github.com/PHPOffice/PhpSpreadsheet/issues/3659) [Issue #1834](https://github.com/PHPOffice/PhpSpreadsheet/issues/1834) [PR #3962](https://github.com/PHPOffice/PhpSpreadsheet/pull/3962) +- String Value Binder Allow Setting "Ignore Number Stored as Text". [PR #4141](https://github.com/PHPOffice/PhpSpreadsheet/pull/4141) ### Changed diff --git a/docs/topics/accessing-cells.md b/docs/topics/accessing-cells.md index 67e177536c..d377da899d 100644 --- a/docs/topics/accessing-cells.md +++ b/docs/topics/accessing-cells.md @@ -551,6 +551,7 @@ By default, the StringValueBinder will cast any datatype passed to it into a str // Set value binder $stringValueBinder = new \PhpOffice\PhpSpreadsheet\Cell\StringValueBinder(); $stringValueBinder->setNumericConversion(false) + ->setSetIgnoredErrors(true) // suppresses "number stored as text" indicators ->setBooleanConversion(false) ->setNullConversion(false) ->setFormulaConversion(false); diff --git a/src/PhpSpreadsheet/Cell/StringValueBinder.php b/src/PhpSpreadsheet/Cell/StringValueBinder.php index d86cdabd33..1509a9c7b6 100644 --- a/src/PhpSpreadsheet/Cell/StringValueBinder.php +++ b/src/PhpSpreadsheet/Cell/StringValueBinder.php @@ -18,6 +18,15 @@ class StringValueBinder extends DefaultValueBinder implements IValueBinder protected bool $convertFormula = true; + protected bool $setIgnoredErrors = false; + + public function setSetIgnoredErrors(bool $setIgnoredErrors = false): self + { + $this->setIgnoredErrors = $setIgnoredErrors; + + return $this; + } + public function setNullConversion(bool $suppressConversion = false): self { $this->convertNull = $suppressConversion; @@ -81,6 +90,7 @@ public function bindValue(Cell $cell, mixed $value): bool $value = StringHelper::sanitizeUTF8($value); } + $ignoredErrors = false; if ($value === null && $this->convertNull === false) { $cell->setValueExplicit($value, DataType::TYPE_NULL); } elseif (is_bool($value) && $this->convertBoolean === false) { @@ -90,8 +100,12 @@ public function bindValue(Cell $cell, mixed $value): bool } elseif (is_string($value) && strlen($value) > 1 && $value[0] === '=' && $this->convertFormula === false && parent::dataTypeForValue($value) === DataType::TYPE_FORMULA) { $cell->setValueExplicit($value, DataType::TYPE_FORMULA); } else { + $ignoredErrors = is_numeric($value); $cell->setValueExplicit((string) $value, DataType::TYPE_STRING); } + if ($this->setIgnoredErrors) { + $cell->getIgnoredErrors()->setNumberStoredAsText($ignoredErrors); + } return true; } @@ -99,16 +113,22 @@ public function bindValue(Cell $cell, mixed $value): bool protected function bindObjectValue(Cell $cell, object $value): bool { // Handle any objects that might be injected + $ignoredErrors = false; if ($value instanceof DateTimeInterface) { $value = $value->format('Y-m-d H:i:s'); $cell->setValueExplicit($value, DataType::TYPE_STRING); } elseif ($value instanceof RichText) { $cell->setValueExplicit($value, DataType::TYPE_INLINE); + $ignoredErrors = is_numeric($value->getPlainText()); } elseif ($value instanceof Stringable) { $cell->setValueExplicit((string) $value, DataType::TYPE_STRING); + $ignoredErrors = is_numeric((string) $value); } else { throw new SpreadsheetException('Unable to bind unstringable object of type ' . get_class($value)); } + if ($this->setIgnoredErrors) { + $cell->getIgnoredErrors()->setNumberStoredAsText($ignoredErrors); + } return true; } diff --git a/tests/PhpSpreadsheetTests/Cell/StringValueBinder2Test.php b/tests/PhpSpreadsheetTests/Cell/StringValueBinder2Test.php new file mode 100644 index 0000000000..5fac7825f4 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Cell/StringValueBinder2Test.php @@ -0,0 +1,129 @@ +valueBinder = Cell::getValueBinder(); + } + + protected function tearDown(): void + { + Cell::setValueBinder($this->valueBinder); + } + + public function testStringValueBinderIgnoredErrorsDefault(): void + { + $valueBinder = new StringValueBinder(); + Cell::setValueBinder($valueBinder); + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $richText = new RichText(); + $richText->createTextRun('6'); + $richText2 = new RichText(); + $richText2->createTextRun('a'); + $sheet->fromArray([ + [1, 'x', 3.2], + ['y', -5, 'z'], + [new DateTime(), $richText, $richText2], + [new StringableObject('a'), new StringableObject(2), 'z'], + ]); + $ignoredCells = []; + foreach ($sheet->getRowIterator() as $row) { + foreach ($row->getCellIterator() as $cell) { + $coordinate = $cell->getCoordinate(); + $dataType = $cell->getDataType(); + if ($dataType !== DataType::TYPE_INLINE) { + self::assertSame(DataType::TYPE_STRING, $dataType, "not string for cell $coordinate"); + } + if ($cell->getIgnoredErrors()->getNumberStoredAsText()) { + $ignoredCells[] = $coordinate; + } + } + } + self::assertSame([], $ignoredCells); + $spreadsheet->disconnectWorksheets(); + } + + public function testStringValueBinderIgnoredErrorsTrue(): void + { + $valueBinder = new StringValueBinder(); + $valueBinder->setSetIgnoredErrors(true); + Cell::setValueBinder($valueBinder); + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $richText = new RichText(); + $richText->createTextRun('6'); + $richText2 = new RichText(); + $richText2->createTextRun('a'); + $sheet->fromArray([ + [1, 'x', 3.2], + ['y', -5, 'z'], + [new DateTime(), $richText, $richText2], + [new StringableObject('a'), new StringableObject(2), 'z'], + ]); + $ignoredCells = []; + foreach ($sheet->getRowIterator() as $row) { + foreach ($row->getCellIterator() as $cell) { + $coordinate = $cell->getCoordinate(); + $dataType = $cell->getDataType(); + if ($dataType !== DataType::TYPE_INLINE) { + self::assertSame(DataType::TYPE_STRING, $dataType, "not string for cell $coordinate"); + } + if ($cell->getIgnoredErrors()->getNumberStoredAsText()) { + $ignoredCells[] = $coordinate; + } + } + } + self::assertSame(['A1', 'C1', 'B2', 'B3', 'B4'], $ignoredCells); + $spreadsheet->disconnectWorksheets(); + } + + public function testStringValueBinderPreserveNumeric(): void + { + $valueBinder = new StringValueBinder(); + $valueBinder->setNumericConversion(false); + $valueBinder->setSetIgnoredErrors(true); + Cell::setValueBinder($valueBinder); + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $richText = new RichText(); + $richText->createTextRun('6'); + $richText2 = new RichText(); + $richText2->createTextRun('a'); + $sheet->fromArray([ + [1, 'x', 3.2], + ['y', -5, 'z'], + [new DateTime(), $richText, $richText2], + [new StringableObject('a'), new StringableObject(2), 'z'], + ]); + $ignoredCells = []; + foreach ($sheet->getRowIterator() as $row) { + foreach ($row->getCellIterator() as $cell) { + $coordinate = $cell->getCoordinate(); + $expected = (is_int($cell->getValue()) || is_float($cell->getValue())) ? DataType::TYPE_NUMERIC : (($cell->getValue() instanceof RichText) ? DataType::TYPE_INLINE : DataType::TYPE_STRING); + self::assertSame($expected, $cell->getDataType(), "wrong type for cell $coordinate"); + if ($cell->getIgnoredErrors()->getNumberStoredAsText()) { + $ignoredCells[] = $coordinate; + } + } + } + self::assertSame(['B3', 'B4'], $ignoredCells); + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Cell/StringableObject.php b/tests/PhpSpreadsheetTests/Cell/StringableObject.php index 31d1a774c2..1ca0f0f66e 100644 --- a/tests/PhpSpreadsheetTests/Cell/StringableObject.php +++ b/tests/PhpSpreadsheetTests/Cell/StringableObject.php @@ -6,8 +6,15 @@ class StringableObject { + private int|string $value; + + public function __construct(int|string $value = 'abc') + { + $this->value = $value; + } + public function __toString(): string { - return 'abc'; + return (string) $this->value; } }