diff --git a/CHANGELOG.md b/CHANGELOG.md index 12b595933..9883c3dd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Added - Xlsx Reader Optionally Ignore Rows With No Cells. [Issue #3982](https://github.com/PHPOffice/PhpSpreadsheet/issues/3982) [PR #4035](https://github.com/PHPOffice/PhpSpreadsheet/pull/4035) +- Means to change style without affecting current cell/sheet. [PR #4073](https://github.com/PHPOffice/PhpSpreadsheet/pull/4073) - Option for CSV output file to have varying numbers of columns for each row. [Issue #1415](https://github.com/PHPOffice/PhpSpreadsheet/issues/1415) [PR #4076](https://github.com/PHPOffice/PhpSpreadsheet/pull/4076) ### Changed @@ -34,10 +35,13 @@ and this project adheres to [Semantic Versioning](https://semver.org). - Mpdf and Tcpdf Borders on Merged Cells. [Issue #3557](https://github.com/PHPOffice/PhpSpreadsheet/issues/3557) [PR #4047](https://github.com/PHPOffice/PhpSpreadsheet/pull/4047) - Xls Conditional Format Improvements. [PR #4030](https://github.com/PHPOffice/PhpSpreadsheet/pull/4030) [PR #4033](https://github.com/PHPOffice/PhpSpreadsheet/pull/4033) - Conditional Range Unions and Intersections [Issue #4039](https://github.com/PHPOffice/PhpSpreadsheet/issues/4039) [PR #4042](https://github.com/PHPOffice/PhpSpreadsheet/pull/4042) +- Ods comments with newlines. [Issue #4081](https://github.com/PHPOffice/PhpSpreadsheet/issues/4081) [PR #4086](https://github.com/PHPOffice/PhpSpreadsheet/pull/4086) +- Propagate errors in Text functions. [Issue #2581](https://github.com/PHPOffice/PhpSpreadsheet/issues/2581) [PR #4080](https://github.com/PHPOffice/PhpSpreadsheet/pull/4080) - Csv Reader allow use of html mimetype. [Issue #4036](https://github.com/PHPOffice/PhpSpreadsheet/issues/4036) [PR #4040](https://github.com/PHPOffice/PhpSpreadsheet/pull/4040) - Problem rendering line chart with missing plot label. [PR #4074](https://github.com/PHPOffice/PhpSpreadsheet/pull/4074) - More RTL in Xlsx/Html Comments [Issue #4004](https://github.com/PHPOffice/PhpSpreadsheet/issues/4004) [PR #4065](https://github.com/PHPOffice/PhpSpreadsheet/pull/4065) - Empty String in sharedStrings. [Issue #4063](https://github.com/PHPOffice/PhpSpreadsheet/issues/4063) [PR #4064](https://github.com/PHPOffice/PhpSpreadsheet/pull/4064) +- Treat invalid formulas as strings. [Issue #1310](https://github.com/PHPOffice/PhpSpreadsheet/issues/1310) [PR #4073](https://github.com/PHPOffice/PhpSpreadsheet/pull/4073) ## 2024-05-11 - 2.1.0 diff --git a/src/PhpSpreadsheet/Calculation/Calculation.php b/src/PhpSpreadsheet/Calculation/Calculation.php index 257d1d06c..2b8a7bc65 100644 --- a/src/PhpSpreadsheet/Calculation/Calculation.php +++ b/src/PhpSpreadsheet/Calculation/Calculation.php @@ -4082,7 +4082,7 @@ class Calculation $opCharacter = $formula[$index]; // Get the first character of the value at the current index position // Check for two-character operators (e.g. >=, <=, <>) - if ((isset(self::$comparisonOperators[$opCharacter])) && (strlen($formula) > $index) && (isset(self::$comparisonOperators[$formula[$index + 1]]))) { + if ((isset(self::$comparisonOperators[$opCharacter])) && (strlen($formula) > $index) && isset($formula[$index + 1], self::$comparisonOperators[$formula[$index + 1]])) { $opCharacter .= $formula[++$index]; } // Find out if we're currently at the beginning of a number, variable, cell/row/column reference, @@ -4810,13 +4810,20 @@ class Calculation for ($row = 0; $row < $rows; ++$row) { for ($column = 0; $column < $columns; ++$column) { - $operand1[$row][$column] - = Shared\StringHelper::substring( - self::boolToString($operand1[$row][$column]) - . self::boolToString($operand2[$row][$column]), - 0, - DataType::MAX_STRING_LENGTH - ); + $op1x = self::boolToString($operand1[$row][$column]); + $op2x = self::boolToString($operand2[$row][$column]); + if (Information\ErrorValue::isError($op1x)) { + // no need to do anything + } elseif (Information\ErrorValue::isError($op2x)) { + $operand1[$row][$column] = $op2x; + } else { + $operand1[$row][$column] + = Shared\StringHelper::substring( + $op1x . $op2x, + 0, + DataType::MAX_STRING_LENGTH + ); + } } } $result = $operand1; @@ -4826,7 +4833,13 @@ class Calculation // using the concatenation operator // with literals that fits in 32K, // so I don't think we can overflow here. - $result = self::FORMULA_STRING_QUOTE . str_replace('""', self::FORMULA_STRING_QUOTE, self::unwrapResult($operand1) . self::unwrapResult($operand2)) . self::FORMULA_STRING_QUOTE; + if (Information\ErrorValue::isError($operand1)) { + $result = $operand1; + } elseif (Information\ErrorValue::isError($operand2)) { + $result = $operand2; + } else { + $result = self::FORMULA_STRING_QUOTE . str_replace('""', self::FORMULA_STRING_QUOTE, self::unwrapResult($operand1) . self::unwrapResult($operand2)) . self::FORMULA_STRING_QUOTE; + } } $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($result)); $stack->push('Value', $result); diff --git a/src/PhpSpreadsheet/Calculation/Functions.php b/src/PhpSpreadsheet/Calculation/Functions.php index 6b7450069..77f8317ae 100644 --- a/src/PhpSpreadsheet/Calculation/Functions.php +++ b/src/PhpSpreadsheet/Calculation/Functions.php @@ -26,6 +26,8 @@ class Functions const RETURNDATE_PHP_DATETIME_OBJECT = 'O'; const RETURNDATE_EXCEL = 'E'; + public const NOT_YET_IMPLEMENTED = '#Not Yet Implemented'; + /** * Compatibility mode to use for error checking and responses. */ @@ -123,7 +125,7 @@ class Functions */ public static function DUMMY(): string { - return '#Not Yet Implemented'; + return self::NOT_YET_IMPLEMENTED; } public static function isMatrixValue(mixed $idx): bool diff --git a/src/PhpSpreadsheet/Calculation/Information/ErrorValue.php b/src/PhpSpreadsheet/Calculation/Information/ErrorValue.php index dcef43990..f3a746273 100644 --- a/src/PhpSpreadsheet/Calculation/Information/ErrorValue.php +++ b/src/PhpSpreadsheet/Calculation/Information/ErrorValue.php @@ -3,6 +3,7 @@ namespace PhpOffice\PhpSpreadsheet\Calculation\Information; use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled; +use PhpOffice\PhpSpreadsheet\Calculation\Functions; class ErrorValue { @@ -35,7 +36,7 @@ class ErrorValue * @return array|bool If an array of numbers is passed as an argument, then the returned result will also be an array * with the same dimensions */ - public static function isError(mixed $value = ''): array|bool + public static function isError(mixed $value = '', bool $tryNotImplemented = false): array|bool { if (is_array($value)) { return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $value); @@ -44,6 +45,9 @@ class ErrorValue if (!is_string($value)) { return false; } + if ($tryNotImplemented && $value === Functions::NOT_YET_IMPLEMENTED) { + return true; + } return in_array($value, ExcelError::ERROR_CODES, true); } diff --git a/src/PhpSpreadsheet/Calculation/TextData/CaseConvert.php b/src/PhpSpreadsheet/Calculation/TextData/CaseConvert.php index 83cc4ee1f..6667bac54 100644 --- a/src/PhpSpreadsheet/Calculation/TextData/CaseConvert.php +++ b/src/PhpSpreadsheet/Calculation/TextData/CaseConvert.php @@ -3,6 +3,7 @@ namespace PhpOffice\PhpSpreadsheet\Calculation\TextData; use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled; +use PhpOffice\PhpSpreadsheet\Calculation\Exception as CalcExp; use PhpOffice\PhpSpreadsheet\Shared\StringHelper; class CaseConvert @@ -26,7 +27,11 @@ class CaseConvert return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $mixedCaseValue); } - $mixedCaseValue = Helpers::extractString($mixedCaseValue); + try { + $mixedCaseValue = Helpers::extractString($mixedCaseValue, true); + } catch (CalcExp $e) { + return $e->getMessage(); + } return StringHelper::strToLower($mixedCaseValue); } @@ -48,7 +53,11 @@ class CaseConvert return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $mixedCaseValue); } - $mixedCaseValue = Helpers::extractString($mixedCaseValue); + try { + $mixedCaseValue = Helpers::extractString($mixedCaseValue, true); + } catch (CalcExp $e) { + return $e->getMessage(); + } return StringHelper::strToUpper($mixedCaseValue); } @@ -70,7 +79,11 @@ class CaseConvert return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $mixedCaseValue); } - $mixedCaseValue = Helpers::extractString($mixedCaseValue); + try { + $mixedCaseValue = Helpers::extractString($mixedCaseValue, true); + } catch (CalcExp $e) { + return $e->getMessage(); + } return StringHelper::strToTitle($mixedCaseValue); } diff --git a/src/PhpSpreadsheet/Calculation/TextData/CharacterConvert.php b/src/PhpSpreadsheet/Calculation/TextData/CharacterConvert.php index 8d90a17ba..06d0f9009 100644 --- a/src/PhpSpreadsheet/Calculation/TextData/CharacterConvert.php +++ b/src/PhpSpreadsheet/Calculation/TextData/CharacterConvert.php @@ -3,6 +3,7 @@ namespace PhpOffice\PhpSpreadsheet\Calculation\TextData; use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled; +use PhpOffice\PhpSpreadsheet\Calculation\Exception as CalcExp; use PhpOffice\PhpSpreadsheet\Calculation\Functions; use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError; @@ -26,7 +27,12 @@ class CharacterConvert return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $character); } - $character = Helpers::validateInt($character); + try { + $character = Helpers::validateInt($character, true); + } catch (CalcExp $e) { + return $e->getMessage(); + } + $min = Functions::getCompatibilityMode() === Functions::COMPATIBILITY_OPENOFFICE ? 0 : 1; if ($character < $min || $character > 255) { return ExcelError::VALUE(); @@ -52,7 +58,12 @@ class CharacterConvert return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $characters); } - $characters = Helpers::extractString($characters); + try { + $characters = Helpers::extractString($characters, true); + } catch (CalcExp $e) { + return $e->getMessage(); + } + if ($characters === '') { return ExcelError::VALUE(); } diff --git a/src/PhpSpreadsheet/Calculation/TextData/Concatenate.php b/src/PhpSpreadsheet/Calculation/TextData/Concatenate.php index 62e9c3895..337a64a60 100644 --- a/src/PhpSpreadsheet/Calculation/TextData/Concatenate.php +++ b/src/PhpSpreadsheet/Calculation/TextData/Concatenate.php @@ -28,7 +28,7 @@ class Concatenate foreach ($aArgs as $arg) { $value = Helpers::extractString($arg); - if (ErrorValue::isError($value)) { + if (ErrorValue::isError($value, true)) { $returnValue = $value; break; @@ -140,7 +140,7 @@ class Concatenate { foreach ($aArgs as $key => &$arg) { $value = Helpers::extractString($arg); - if (ErrorValue::isError($value)) { + if (ErrorValue::isError($value, true)) { return $value; } @@ -178,7 +178,7 @@ class Concatenate if (!is_numeric($repeatCount) || $repeatCount < 0) { $returnValue = ExcelError::VALUE(); - } elseif (ErrorValue::isError($stringValue)) { + } elseif (ErrorValue::isError($stringValue, true)) { $returnValue = $stringValue; } else { $returnValue = str_repeat($stringValue, (int) $repeatCount); diff --git a/src/PhpSpreadsheet/Calculation/TextData/Extract.php b/src/PhpSpreadsheet/Calculation/TextData/Extract.php index 32e5b967a..1dfb724cd 100644 --- a/src/PhpSpreadsheet/Calculation/TextData/Extract.php +++ b/src/PhpSpreadsheet/Calculation/TextData/Extract.php @@ -31,7 +31,7 @@ class Extract } try { - $value = Helpers::extractString($value); + $value = Helpers::extractString($value, true); $chars = Helpers::extractInt($chars, 0, 1); } catch (CalcExp $e) { return $e->getMessage(); @@ -61,7 +61,7 @@ class Extract } try { - $value = Helpers::extractString($value); + $value = Helpers::extractString($value, true); $start = Helpers::extractInt($start, 1); $chars = Helpers::extractInt($chars, 0); } catch (CalcExp $e) { @@ -90,7 +90,7 @@ class Extract } try { - $value = Helpers::extractString($value); + $value = Helpers::extractString($value, true); $chars = Helpers::extractInt($chars, 0, 1); } catch (CalcExp $e) { return $e->getMessage(); @@ -132,7 +132,13 @@ class Extract return self::evaluateArrayArgumentsIgnore([self::class, __FUNCTION__], 1, $text, $delimiter, $instance, $matchMode, $matchEnd, $ifNotFound); } - $text = Helpers::extractString($text ?? ''); + try { + $text = Helpers::extractString($text ?? '', true); + Helpers::extractString(Functions::flattenSingleValue($delimiter ?? ''), true); + } catch (CalcExp $e) { + return $e->getMessage(); + } + $instance = (int) $instance; $matchMode = (int) $matchMode; $matchEnd = (int) $matchEnd; @@ -190,7 +196,13 @@ class Extract return self::evaluateArrayArgumentsIgnore([self::class, __FUNCTION__], 1, $text, $delimiter, $instance, $matchMode, $matchEnd, $ifNotFound); } - $text = Helpers::extractString($text ?? ''); + try { + $text = Helpers::extractString($text ?? '', true); + Helpers::extractString(Functions::flattenSingleValue($delimiter ?? ''), true); + } catch (CalcExp $e) { + return $e->getMessage(); + } + $instance = (int) $instance; $matchMode = (int) $matchMode; $matchEnd = (int) $matchEnd; diff --git a/src/PhpSpreadsheet/Calculation/TextData/Format.php b/src/PhpSpreadsheet/Calculation/TextData/Format.php index 40335ced7..0560b376b 100644 --- a/src/PhpSpreadsheet/Calculation/TextData/Format.php +++ b/src/PhpSpreadsheet/Calculation/TextData/Format.php @@ -8,6 +8,7 @@ use PhpOffice\PhpSpreadsheet\Calculation\Calculation; use PhpOffice\PhpSpreadsheet\Calculation\DateTimeExcel; use PhpOffice\PhpSpreadsheet\Calculation\Exception as CalcExp; use PhpOffice\PhpSpreadsheet\Calculation\Functions; +use PhpOffice\PhpSpreadsheet\Calculation\Information\ErrorValue; use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError; use PhpOffice\PhpSpreadsheet\Calculation\MathTrig; use PhpOffice\PhpSpreadsheet\RichText\RichText; @@ -123,8 +124,13 @@ class Format return self::evaluateArrayArguments([self::class, __FUNCTION__], $value, $format); } - $value = Helpers::extractString($value); - $format = Helpers::extractString($format); + try { + $value = Helpers::extractString($value, true); + $format = Helpers::extractString($format, true); + } catch (CalcExp $e) { + return $e->getMessage(); + } + $format = (string) NumberFormat::convertSystemFormats($format); if (!is_numeric($value) && Date::isDateTimeFormatCode($format)) { @@ -152,6 +158,9 @@ class Format } if (is_string($value)) { $value = trim($value); + if (ErrorValue::isError($value, true)) { + throw new CalcExp($value); + } if ($spacesMeanZero && $value === '') { $value = 0; } @@ -220,7 +229,7 @@ class Format } /** - * TEXT. + * VALUETOTEXT. * * @param mixed $value The value to format * Or can be an array of values diff --git a/src/PhpSpreadsheet/Calculation/TextData/Helpers.php b/src/PhpSpreadsheet/Calculation/TextData/Helpers.php index 15b046704..719de04a8 100644 --- a/src/PhpSpreadsheet/Calculation/TextData/Helpers.php +++ b/src/PhpSpreadsheet/Calculation/TextData/Helpers.php @@ -27,7 +27,7 @@ class Helpers if (is_bool($value)) { return self::convertBooleanValue($value); } - if ($throwIfError && is_string($value) && ErrorValue::isError($value)) { + if ($throwIfError && is_string($value) && ErrorValue::isError($value, true)) { throw new CalcExp($value); } @@ -63,18 +63,28 @@ class Helpers $value = (float) $value; } if (!is_numeric($value)) { + if (is_string($value) && ErrorValue::isError($value, true)) { + throw new CalcExp($value); + } + throw new CalcExp(ExcelError::VALUE()); } return (float) $value; } - public static function validateInt(mixed $value): int + public static function validateInt(mixed $value, bool $throwIfError = false): int { if ($value === null) { $value = 0; } elseif (is_bool($value)) { $value = (int) $value; + } elseif ($throwIfError && is_string($value) && !is_numeric($value)) { + if (!ErrorValue::isError($value, true)) { + $value = ExcelError::VALUE(); + } + + throw new CalcExp($value); } return (int) $value; diff --git a/src/PhpSpreadsheet/Calculation/TextData/Search.php b/src/PhpSpreadsheet/Calculation/TextData/Search.php index ad83f1a37..663d49fc2 100644 --- a/src/PhpSpreadsheet/Calculation/TextData/Search.php +++ b/src/PhpSpreadsheet/Calculation/TextData/Search.php @@ -32,8 +32,8 @@ class Search } try { - $needle = Helpers::extractString($needle); - $haystack = Helpers::extractString($haystack); + $needle = Helpers::extractString($needle, true); + $haystack = Helpers::extractString($haystack, true); $offset = Helpers::extractInt($offset, 1, 0, true); } catch (CalcExp $e) { return $e->getMessage(); @@ -74,8 +74,8 @@ class Search } try { - $needle = Helpers::extractString($needle); - $haystack = Helpers::extractString($haystack); + $needle = Helpers::extractString($needle, true); + $haystack = Helpers::extractString($haystack, true); $offset = Helpers::extractInt($offset, 1, 0, true); } catch (CalcExp $e) { return $e->getMessage(); diff --git a/src/PhpSpreadsheet/Calculation/TextData/Text.php b/src/PhpSpreadsheet/Calculation/TextData/Text.php index 44e0cd402..f988a6c19 100644 --- a/src/PhpSpreadsheet/Calculation/TextData/Text.php +++ b/src/PhpSpreadsheet/Calculation/TextData/Text.php @@ -4,6 +4,7 @@ namespace PhpOffice\PhpSpreadsheet\Calculation\TextData; use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled; use PhpOffice\PhpSpreadsheet\Calculation\Calculation; +use PhpOffice\PhpSpreadsheet\Calculation\Exception as CalcExp; use PhpOffice\PhpSpreadsheet\Calculation\Functions; use PhpOffice\PhpSpreadsheet\Calculation\Information\ErrorValue; @@ -17,16 +18,20 @@ class Text * @param mixed $value String Value * Or can be an array of values * - * @return array|int If an array of values is passed for the argument, then the returned result + * @return array|int|string If an array of values is passed for the argument, then the returned result * will also be an array with matching dimensions */ - public static function length(mixed $value = ''): array|int + public static function length(mixed $value = ''): array|int|string { if (is_array($value)) { return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $value); } - $value = Helpers::extractString($value); + try { + $value = Helpers::extractString($value, true); + } catch (CalcExp $e) { + return $e->getMessage(); + } return mb_strlen($value, 'UTF-8'); } @@ -41,17 +46,21 @@ class Text * @param mixed $value2 String Value * Or can be an array of values * - * @return array|bool If an array of values is passed for either of the arguments, then the returned result + * @return array|bool|string If an array of values is passed for either of the arguments, then the returned result * will also be an array with matching dimensions */ - public static function exact(mixed $value1, mixed $value2): array|bool + public static function exact(mixed $value1, mixed $value2): array|bool|string { if (is_array($value1) || is_array($value2)) { return self::evaluateArrayArguments([self::class, __FUNCTION__], $value1, $value2); } - $value1 = Helpers::extractString($value1); - $value2 = Helpers::extractString($value2); + try { + $value1 = Helpers::extractString($value1, true); + $value2 = Helpers::extractString($value2, true); + } catch (CalcExp $e) { + return $e->getMessage(); + } return $value2 === $value1; } @@ -97,11 +106,14 @@ class Text * @param mixed $padding The value with which to pad the result. * The default is #N/A. * - * @return array the array built from the text, split by the row and column delimiters + * @return array|string the array built from the text, split by the row and column delimiters, or an error string */ - public static function split(mixed $text, $columnDelimiter = null, $rowDelimiter = null, bool $ignoreEmpty = false, bool $matchMode = true, mixed $padding = '#N/A'): array + public static function split(mixed $text, $columnDelimiter = null, $rowDelimiter = null, bool $ignoreEmpty = false, bool $matchMode = true, mixed $padding = '#N/A'): array|string { $text = Functions::flattenSingleValue($text); + if (ErrorValue::isError($text, true)) { + return $text; + } $flags = self::matchFlags($matchMode); diff --git a/src/PhpSpreadsheet/Cell/Cell.php b/src/PhpSpreadsheet/Cell/Cell.php index e205b73ee..29649405a 100644 --- a/src/PhpSpreadsheet/Cell/Cell.php +++ b/src/PhpSpreadsheet/Cell/Cell.php @@ -4,6 +4,7 @@ namespace PhpOffice\PhpSpreadsheet\Cell; use PhpOffice\PhpSpreadsheet\Calculation\Calculation; use PhpOffice\PhpSpreadsheet\Calculation\Exception as CalculationException; +use PhpOffice\PhpSpreadsheet\Calculation\Functions; use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError; use PhpOffice\PhpSpreadsheet\Collection\Cells; use PhpOffice\PhpSpreadsheet\Exception as SpreadsheetException; @@ -257,6 +258,7 @@ class Cell implements Stringable public function setValueExplicit(mixed $value, string $dataType = DataType::TYPE_STRING): self { $oldValue = $this->value; + $quotePrefix = false; // set the value according to data type switch ($dataType) { @@ -269,6 +271,10 @@ class Cell implements Stringable // no break case DataType::TYPE_STRING: // Synonym for string + if (is_string($value) && strlen($value) > 1 && $value[0] === '=') { + $quotePrefix = true; + } + // no break case DataType::TYPE_INLINE: // Rich text if ($value !== null && !is_scalar($value) && !($value instanceof Stringable)) { @@ -314,6 +320,7 @@ class Cell implements Stringable $this->updateInCollection(); $cellCoordinate = $this->getCoordinate(); self::updateIfCellIsTableHeader($this->getParent()?->getParent(), $this, $oldValue, $value); + $this->getWorksheet()->applyStylesFromArray($cellCoordinate, ['quotePrefix' => $quotePrefix]); return $this->getParent()?->get($cellCoordinate) ?? $this; } @@ -536,7 +543,7 @@ class Cell implements Stringable } SharedDate::setExcelCalendar($currentCalendar); - if ($result === '#Not Yet Implemented') { + if ($result === Functions::NOT_YET_IMPLEMENTED) { return $this->calculatedValue; // Fallback if calculation engine does not support the formula. } diff --git a/src/PhpSpreadsheet/Cell/DefaultValueBinder.php b/src/PhpSpreadsheet/Cell/DefaultValueBinder.php index 6f75a90a8..f36934ed3 100644 --- a/src/PhpSpreadsheet/Cell/DefaultValueBinder.php +++ b/src/PhpSpreadsheet/Cell/DefaultValueBinder.php @@ -3,6 +3,8 @@ namespace PhpOffice\PhpSpreadsheet\Cell; use DateTimeInterface; +use PhpOffice\PhpSpreadsheet\Calculation\Calculation; +use PhpOffice\PhpSpreadsheet\Calculation\Exception as CalculationException; use PhpOffice\PhpSpreadsheet\Exception as SpreadsheetException; use PhpOffice\PhpSpreadsheet\RichText\RichText; use PhpOffice\PhpSpreadsheet\Shared\StringHelper; @@ -68,6 +70,23 @@ class DefaultValueBinder implements IValueBinder throw new SpreadsheetException("unusable type $gettype"); } if (strlen($value) > 1 && $value[0] === '=') { + $calculation = new Calculation(); + $calculation->disableBranchPruning(); + + try { + if (empty($calculation->parseFormula($value))) { + return DataType::TYPE_STRING; + } + } catch (CalculationException $e) { + $message = $e->getMessage(); + if ( + $message === 'Formula Error: An unexpected error occurred' + || str_contains($message, 'has no operands') + ) { + return DataType::TYPE_STRING; + } + } + return DataType::TYPE_FORMULA; } if (preg_match('/^[\+\-]?(\d+\\.?\d*|\d*\\.?\d+)([Ee][\-\+]?[0-2]?\d{1,3})?$/', $value)) { diff --git a/src/PhpSpreadsheet/Cell/StringValueBinder.php b/src/PhpSpreadsheet/Cell/StringValueBinder.php index 6ff258d93..d86cdabd3 100644 --- a/src/PhpSpreadsheet/Cell/StringValueBinder.php +++ b/src/PhpSpreadsheet/Cell/StringValueBinder.php @@ -8,7 +8,7 @@ use PhpOffice\PhpSpreadsheet\RichText\RichText; use PhpOffice\PhpSpreadsheet\Shared\StringHelper; use Stringable; -class StringValueBinder implements IValueBinder +class StringValueBinder extends DefaultValueBinder implements IValueBinder { protected bool $convertNull = true; @@ -87,12 +87,9 @@ class StringValueBinder implements IValueBinder $cell->setValueExplicit($value, DataType::TYPE_BOOL); } elseif ((is_int($value) || is_float($value)) && $this->convertNumeric === false) { $cell->setValueExplicit($value, DataType::TYPE_NUMERIC); - } elseif (is_string($value) && strlen($value) > 1 && $value[0] === '=' && $this->convertFormula === false) { + } 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 { - if (is_string($value) && strlen($value) > 1 && $value[0] === '=') { - $cell->getStyle()->setQuotePrefix(true); - } $cell->setValueExplicit((string) $value, DataType::TYPE_STRING); } diff --git a/src/PhpSpreadsheet/Reader/Ods.php b/src/PhpSpreadsheet/Reader/Ods.php index 2214369bc..0eefc8452 100644 --- a/src/PhpSpreadsheet/Reader/Ods.php +++ b/src/PhpSpreadsheet/Reader/Ods.php @@ -452,14 +452,25 @@ class Ods extends BaseReader if ($annotation->length > 0 && $annotation->item(0) !== null) { $textNode = $annotation->item(0)->getElementsByTagNameNS($textNs, 'p'); + $textNodeLength = $textNode->length; + $newLineOwed = false; + for ($textNodeIndex = 0; $textNodeIndex < $textNodeLength; ++$textNodeIndex) { + $textNodeItem = $textNode->item($textNodeIndex); + if ($textNodeItem !== null) { + $text = $this->scanElementForText($textNodeItem); + if ($newLineOwed) { + $spreadsheet->getActiveSheet() + ->getComment($columnID . $rowID) + ->getText() + ->createText("\n"); + } + $newLineOwed = true; - if ($textNode->length > 0 && $textNode->item(0) !== null) { - $text = $this->scanElementForText($textNode->item(0)); - - $spreadsheet->getActiveSheet() - ->getComment($columnID . $rowID) - ->setText($this->parseRichText($text)); -// ->setAuthor( $author ) + $spreadsheet->getActiveSheet() + ->getComment($columnID . $rowID) + ->getText() + ->createText($this->parseRichText($text)); + } } } @@ -750,6 +761,8 @@ class Ods extends BaseReader /** @var DOMNode $child */ if ($child->nodeType == XML_TEXT_NODE) { $str .= $child->nodeValue; + } elseif ($child->nodeType == XML_ELEMENT_NODE && $child->nodeName == 'text:line-break') { + $str .= "\n"; } elseif ($child->nodeType == XML_ELEMENT_NODE && $child->nodeName == 'text:s') { // It's a space diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index 8119547d3..ae80cbc0b 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -3714,5 +3714,20 @@ class Worksheet implements IComparable } return false; + } + + public function applyStylesFromArray(string $coordinate, array $styleArray): bool + { + $spreadsheet = $this->parent; + if ($spreadsheet === null) { + return false; + } + $activeSheetIndex = $spreadsheet->getActiveSheetIndex(); + $originalSelected = $this->selectedCells; + $this->getStyle($coordinate)->applyFromArray($styleArray); + $this->selectedCells = $originalSelected; + $spreadsheet->setActiveSheetIndex($activeSheetIndex); + + return true; } } diff --git a/src/PhpSpreadsheet/Writer/Ods/Cell/Comment.php b/src/PhpSpreadsheet/Writer/Ods/Cell/Comment.php index b0829bf1d..f0b7d5704 100644 --- a/src/PhpSpreadsheet/Writer/Ods/Cell/Comment.php +++ b/src/PhpSpreadsheet/Writer/Ods/Cell/Comment.php @@ -24,7 +24,22 @@ class Comment $objWriter->writeAttribute('svg:x', $comment->getMarginLeft()); $objWriter->writeAttribute('svg:y', $comment->getMarginTop()); $objWriter->writeElement('dc:creator', $comment->getAuthor()); - $objWriter->writeElement('text:p', $comment->getText()->getPlainText()); + + $objWriter->startElement('text:p'); + $text = $comment->getText()->getPlainText(); + $textElements = explode("\n", $text); + $newLineOwed = false; + foreach ($textElements as $textSegment) { + if ($newLineOwed) { + $objWriter->writeElement('text:line-break'); + } + $newLineOwed = true; + if ($textSegment !== '') { + $objWriter->writeElement('text:span', $textSegment); + } + } + $objWriter->endElement(); // text:p + $objWriter->endElement(); } } diff --git a/tests/PhpSpreadsheetTests/Calculation/Engine/RangeTest.php b/tests/PhpSpreadsheetTests/Calculation/Engine/RangeTest.php index fbac0ff91..aa7bc529d 100644 --- a/tests/PhpSpreadsheetTests/Calculation/Engine/RangeTest.php +++ b/tests/PhpSpreadsheetTests/Calculation/Engine/RangeTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace PhpOffice\PhpSpreadsheetTests\Calculation\Engine; +use PhpOffice\PhpSpreadsheet\Calculation\Calculation; use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError; use PhpOffice\PhpSpreadsheet\NamedRange; use PhpOffice\PhpSpreadsheet\Spreadsheet; @@ -13,13 +14,23 @@ class RangeTest extends TestCase { private string $incompleteMessage = 'Must be revisited'; - private Spreadsheet $spreadSheet; + private ?Spreadsheet $spreadSheet = null; - protected function setUp(): void + protected function getSpreadsheet(): Spreadsheet { - $this->spreadSheet = new Spreadsheet(); - $this->spreadSheet->getActiveSheet() + $spreadsheet = new Spreadsheet(); + $spreadsheet->getActiveSheet() ->fromArray(array_chunk(range(1, 240), 6), null, 'A1', true); + + return $spreadsheet; + } + + protected function tearDown(): void + { + if ($this->spreadSheet !== null) { + $this->spreadSheet->disconnectWorksheets(); + $this->spreadSheet = null; + } } /** @@ -27,6 +38,7 @@ class RangeTest extends TestCase */ public function testRangeEvaluation(string $formula, int|string $expectedResult): void { + $this->spreadSheet = $this->getSpreadsheet(); $workSheet = $this->spreadSheet->getActiveSheet(); $workSheet->setCellValue('H1', $formula); @@ -64,8 +76,20 @@ class RangeTest extends TestCase ]; } + public function test3dRangeParsing(): void + { + // This test shows that parsing throws exception. + // Next test shows that formula is still treated as a formula + // despite the parse failure. + $this->expectExceptionMessage('3D Range references are not yet supported'); + $calculation = new Calculation(); + $calculation->disableBranchPruning(); + $calculation->parseFormula('=SUM(Worksheet!A1:Worksheet2!B3'); + } + public function test3dRangeEvaluation(): void { + $this->spreadSheet = $this->getSpreadsheet(); $workSheet = $this->spreadSheet->getActiveSheet(); $workSheet->setCellValue('E1', '=SUM(Worksheet!A1:Worksheet2!B3)'); @@ -78,6 +102,7 @@ class RangeTest extends TestCase */ public function testNamedRangeEvaluation(array $ranges, string $formula, int $expectedResult): void { + $this->spreadSheet = $this->getSpreadsheet(); $workSheet = $this->spreadSheet->getActiveSheet(); foreach ($ranges as $id => $range) { $this->spreadSheet->addNamedRange(new NamedRange('GROUP' . ++$id, $workSheet, $range)); @@ -116,6 +141,7 @@ class RangeTest extends TestCase */ public function testUTF8NamedRangeEvaluation(array $names, array $ranges, string $formula, int $expectedResult): void { + $this->spreadSheet = $this->getSpreadsheet(); $workSheet = $this->spreadSheet->getActiveSheet(); foreach ($names as $index => $name) { $range = $ranges[$index]; @@ -144,6 +170,7 @@ class RangeTest extends TestCase if ($this->incompleteMessage !== '') { self::markTestIncomplete($this->incompleteMessage); } + $this->spreadSheet = $this->getSpreadsheet(); $workSheet = $this->spreadSheet->getActiveSheet(); $this->spreadSheet->addNamedRange(new NamedRange('COMPOSITE', $workSheet, $composite)); diff --git a/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ErrorPropagationTest.php b/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ErrorPropagationTest.php new file mode 100644 index 000000000..9ab19d94c --- /dev/null +++ b/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ErrorPropagationTest.php @@ -0,0 +1,96 @@ +getSheet(); + $sheet->getCell('A1')->setValue('=ABS("X")'); + self::assertSame('#VALUE!', $sheet->getCell('A1')->getCalculatedValue()); + $sheet->getCell('A2')->setValue('=SQRT(-1)'); + self::assertSame('#NUM!', $sheet->getCell('A2')->getCalculatedValue()); + $sheet->getCell('A3')->setValue('=3/0'); + self::assertSame('#DIV/0!', $sheet->getCell('A3')->getCalculatedValue()); + $sheet->getCell('A4')->setValue('=XXXX()'); + self::assertSame('#NAME?', $sheet->getCell('A4')->getCalculatedValue()); + $sheet->getCell('A5')->setValue('=ABS("X")'); + self::assertSame('#VALUE!', $sheet->getCell('A5')->getCalculatedValue()); + + $sheet->getCell('B1')->setValue('=UPPER(A1)'); + self::assertSame('#VALUE!', $sheet->getCell('A1')->getCalculatedValue()); + $sheet->getCell('B2')->setValue('=LOWER(A2)'); + self::assertSame('#NUM!', $sheet->getCell('A2')->getCalculatedValue()); + $sheet->getCell('B3')->setValue('=PROPER(A3)'); + self::assertSame('#DIV/0!', $sheet->getCell('A3')->getCalculatedValue()); + + $sheet->getCell('C2')->setValue('=CHAR(A2)'); + self::assertSame('#NUM!', $sheet->getCell('C2')->getCalculatedValue()); + $sheet->getCell('C3')->setValue('=CODE(A3)'); + self::assertSame('#DIV/0!', $sheet->getCell('C3')->getCalculatedValue()); + + $sheet->getCell('D1')->setValue('=CONCATENATE(A1,A1)'); + self::assertSame('#VALUE!', $sheet->getCell('D1')->getCalculatedValue()); + $sheet->getCell('D2')->setValue('=TEXTJOIN(",",TRUE,A2,A3)'); + self::assertSame('#NUM!', $sheet->getCell('D2')->getCalculatedValue()); + $sheet->getCell('D3')->setValue('=REPT(A3,3)'); + self::assertSame('#DIV/0!', $sheet->getCell('D3')->getCalculatedValue()); + $sheet->getCell('D4')->setValue('=CONCAT(A4,A4)'); + self::assertSame('#NAME?', $sheet->getCell('D4')->getCalculatedValue()); + $sheet->getCell('D5')->setValue('="X"&A4'); + self::assertSame('#NAME?', $sheet->getCell('D5')->getCalculatedValue()); + $sheet->getCell('D6')->setValue('=A2&"X"'); + self::assertSame('#NUM!', $sheet->getCell('D6')->getCalculatedValue()); + + $sheet->getCell('E1')->setValue('=LEFT(A1)'); + self::assertSame('#VALUE!', $sheet->getCell('E1')->getCalculatedValue()); + $sheet->getCell('E2')->setValue('=RIGHT(A2)'); + self::assertSame('#NUM!', $sheet->getCell('E2')->getCalculatedValue()); + $sheet->getCell('E3')->setValue('=MID(A3,2,2)'); + self::assertSame('#DIV/0!', $sheet->getCell('E3')->getCalculatedValue()); + $sheet->getCell('E4')->setValue('=TEXTBEFORE(A4,"M")'); + self::assertSame('#NAME?', $sheet->getCell('E4')->getCalculatedValue()); + $sheet->getCell('E5')->setValue('=TEXTAFTER(A5,"U")'); + self::assertSame('#VALUE!', $sheet->getCell('E5')->getCalculatedValue()); + + $sheet->getCell('F1')->setValue('=VALUETOTEXT(A1)'); + self::assertSame('#VALUE!', $sheet->getCell('F1')->getCalculatedValue()); + $sheet->getCell('F2')->setValue('=DOLLAR(A2)'); + self::assertSame('#NUM!', $sheet->getCell('F2')->getCalculatedValue()); + $sheet->getCell('F3')->setValue('=FIXED(A3)'); + self::assertSame('#DIV/0!', $sheet->getCell('E3')->getCalculatedValue()); + $sheet->getCell('F4')->setValue('=TEXT(A4,"M")'); + self::assertSame('#NAME?', $sheet->getCell('F4')->getCalculatedValue()); + $sheet->getCell('F5')->setValue('=VALUE(A2)'); + self::assertSame('#NUM!', $sheet->getCell('F5')->getCalculatedValue()); + $sheet->getCell('F6')->setValue('=NUMBERVALUE(A3)'); + self::assertSame('#DIV/0!', $sheet->getCell('F6')->getCalculatedValue()); + + $sheet->getCell('G1')->setValue('=REPLACE("oldtext",2,2,A1)'); + self::assertSame('#VALUE!', $sheet->getCell('G1')->getCalculatedValue()); + $sheet->getCell('G2')->setValue('=SUBSTITUTE(A2,"U","V")'); + self::assertSame('#NUM!', $sheet->getCell('G2')->getCalculatedValue()); + + $sheet->getCell('H1')->setValue('=FIND(A1, "U")'); + self::assertSame('#VALUE!', $sheet->getCell('H1')->getCalculatedValue()); + $sheet->getCell('H2')->setValue('=SEARCH(A2,"U")'); + self::assertSame('#NUM!', $sheet->getCell('H2')->getCalculatedValue()); + + $sheet->getCell('I1')->setValue('=LEN(A1)'); + self::assertSame('#VALUE!', $sheet->getCell('I1')->getCalculatedValue()); + $sheet->getCell('I2')->setValue('=EXACT(A2,A2)'); + self::assertSame('#NUM!', $sheet->getCell('I2')->getCalculatedValue()); + $sheet->getCell('I3')->setValue('=T(A3)'); + self::assertSame('#DIV/0!', $sheet->getCell('I3')->getCalculatedValue()); + $sheet->getCell('I4')->setValue('=TEXTSPLIT(A4,"M")'); + self::assertSame('#NAME?', $sheet->getCell('I4')->getCalculatedValue()); + + $sheet->getCell('J1')->setValue('=TRIM(A1)'); + self::assertSame('#VALUE!', $sheet->getCell('J1')->getCalculatedValue()); + $sheet->getCell('J2')->setValue('=CLEAN(A2)'); + self::assertSame('#NUM!', $sheet->getCell('J2')->getCalculatedValue()); + } +} diff --git a/tests/PhpSpreadsheetTests/Cell/AdvancedValueBinderTest.php b/tests/PhpSpreadsheetTests/Cell/AdvancedValueBinderTest.php index d3f0ad664..d8de97a33 100644 --- a/tests/PhpSpreadsheetTests/Cell/AdvancedValueBinderTest.php +++ b/tests/PhpSpreadsheetTests/Cell/AdvancedValueBinderTest.php @@ -6,6 +6,7 @@ namespace PhpOffice\PhpSpreadsheetTests\Cell; use PhpOffice\PhpSpreadsheet\Cell\AdvancedValueBinder; use PhpOffice\PhpSpreadsheet\Cell\Cell; +use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\Cell\IValueBinder; use PhpOffice\PhpSpreadsheet\Settings; use PhpOffice\PhpSpreadsheet\Shared\StringHelper; @@ -232,4 +233,31 @@ class AdvancedValueBinderTest extends TestCase ["Hello\nWorld", true], ]; } + + /** + * @dataProvider formulaProvider + */ + public function testFormula(string $value, string $dataType): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + + $sheet->getCell('A1')->setValue($value); + self::assertSame($dataType, $sheet->getCell('A1')->getDataType()); + if ($dataType === DataType::TYPE_FORMULA) { + self::assertFalse($sheet->getStyle('A1')->getQuotePrefix()); + } else { + self::assertTrue($sheet->getStyle('A1')->getQuotePrefix()); + } + + $spreadsheet->disconnectWorksheets(); + } + + public static function formulaProvider(): array + { + return [ + 'normal formula' => ['=SUM(A1:C3)', DataType::TYPE_FORMULA], + 'issue 1310' => ['======', DataType::TYPE_STRING], + ]; + } } diff --git a/tests/PhpSpreadsheetTests/Cell/StringValueBinderTest.php b/tests/PhpSpreadsheetTests/Cell/StringValueBinderTest.php index 43ac2fbd1..71006fff8 100644 --- a/tests/PhpSpreadsheetTests/Cell/StringValueBinderTest.php +++ b/tests/PhpSpreadsheetTests/Cell/StringValueBinderTest.php @@ -211,13 +211,19 @@ class StringValueBinderTest extends TestCase $cell->setValue($value); self::assertSame($expectedValue, $cell->getValue()); self::assertSame($expectedDataType, $cell->getDataType()); + if ($expectedDataType === DataType::TYPE_FORMULA) { + self::assertFalse($sheet->getStyle('A1')->getQuotePrefix()); + } else { + self::assertTrue($sheet->getStyle('A1')->getQuotePrefix()); + } $spreadsheet->disconnectWorksheets(); } public static function providerDataValuesSuppressFormulaConversion(): array { return [ - ['=SUM(A1:C3)', '=SUM(A1:C3)', DataType::TYPE_FORMULA, false], + 'normal formula' => ['=SUM(A1:C3)', '=SUM(A1:C3)', DataType::TYPE_FORMULA], + 'issue 1310' => ['======', '======', DataType::TYPE_STRING], ]; } diff --git a/tests/PhpSpreadsheetTests/Reader/Ods/MultiLineCommentTest.php b/tests/PhpSpreadsheetTests/Reader/Ods/MultiLineCommentTest.php new file mode 100644 index 000000000..f6fd5657a --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Ods/MultiLineCommentTest.php @@ -0,0 +1,41 @@ +load($filename); + $sheet = $spreadsheet->getActiveSheet(); + self::assertSame("First line.\n\nSecond line.", $sheet->getComment('A1')->getText()->getPlainText()); + $spreadsheet->disconnectWorksheets(); + } + + public function testOneParagraphMultipleSpans(): void + { + $spreadsheetOld = new Spreadsheet(); + $sheetOld = $spreadsheetOld->getActiveSheet(); + $sheetOld->getCell('A1')->setValue('Hello'); + $text = $sheetOld->getComment('A1')->getText(); + $text->createText('First'); + $text->createText(' line.'); + $text->createText("\n"); + $text->createText("\n"); + $text->createText("Second line.\nThird line."); + $spreadsheet = $this->writeAndReload($spreadsheetOld, 'Ods'); + $spreadsheetOld->disconnectWorksheets(); + + $sheet = $spreadsheet->getActiveSheet(); + self::assertSame("First line.\n\nSecond line.\nThird line.", $sheet->getComment('A1')->getText()->getPlainText()); + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Reader/Xlsx/Issue2581Test.php b/tests/PhpSpreadsheetTests/Reader/Xlsx/Issue2581Test.php new file mode 100644 index 000000000..6ba87bd53 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Xlsx/Issue2581Test.php @@ -0,0 +1,25 @@ +load($filename); + $sheet = $spreadsheet->getActiveSheet(); + self::assertSame('=CONCATENATE("Prefix ",MID(CELL("filename"),FIND("]",CELL("filename"))+1,255), " Suffix")', $sheet->getCell('B1')->getValue()); + self::assertSame('Prefix SomeName Suffix', $sheet->getCell('B1')->getCalculatedValue()); + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/data/Cell/DefaultValueBinder.php b/tests/data/Cell/DefaultValueBinder.php index b9f3d51e1..bd4295c0b 100644 --- a/tests/data/Cell/DefaultValueBinder.php +++ b/tests/data/Cell/DefaultValueBinder.php @@ -83,4 +83,7 @@ return [ 's', '1234567890123459012345689012345690', ], + 'Issue 1310 Multiple = at start' => ['s', '======'], + 'Issue 1310 Variant 1' => ['s', '= ====='], + 'Issue 1310 Variant 2' => ['s', '=2*3='], ]; diff --git a/tests/data/Reader/Ods/issue.4081.ods b/tests/data/Reader/Ods/issue.4081.ods new file mode 100644 index 000000000..6d690bd4e Binary files /dev/null and b/tests/data/Reader/Ods/issue.4081.ods differ diff --git a/tests/data/Reader/XLSX/issue.2581.xlsx b/tests/data/Reader/XLSX/issue.2581.xlsx new file mode 100644 index 000000000..82af1c627 Binary files /dev/null and b/tests/data/Reader/XLSX/issue.2581.xlsx differ