diff --git a/infra/DocumentGenerator.php b/infra/DocumentGenerator.php index 85742d278..c142c434b 100644 --- a/infra/DocumentGenerator.php +++ b/infra/DocumentGenerator.php @@ -9,6 +9,13 @@ use UnexpectedValueException; class DocumentGenerator { + private const EXCLUDED_FUNCTIONS = [ + 'CEILING.ODS', + 'CEILING.XCL', + 'FLOOR.ODS', + 'FLOOR.XCL', + ]; + /** * @param array $phpSpreadsheetFunctions */ @@ -23,6 +30,9 @@ class DocumentGenerator $result .= self::tableRow($lengths, ['Excel Function', 'PhpSpreadsheet Function']) . "\n"; $result .= self::tableRow($lengths, null) . "\n"; foreach ($phpSpreadsheetFunctions as $excelFunction => $functionInfo) { + if (in_array($excelFunction, self::EXCLUDED_FUNCTIONS, true)) { + continue; + } if ($category === $functionInfo['category']) { $phpFunction = self::getPhpSpreadsheetFunctionText($functionInfo['functionCall']); $result .= self::tableRow($lengths, [$excelFunction, $phpFunction]) . "\n"; @@ -87,6 +97,9 @@ class DocumentGenerator $result = "# Function list by name\n"; $lastAlphabet = null; foreach ($phpSpreadsheetFunctions as $excelFunction => $functionInfo) { + if (in_array($excelFunction, self::EXCLUDED_FUNCTIONS, true)) { + continue; + } $lengths = [25, 31, 37]; if ($lastAlphabet !== $excelFunction[0]) { $lastAlphabet = $excelFunction[0]; diff --git a/src/PhpSpreadsheet/Calculation/FunctionArray.php b/src/PhpSpreadsheet/Calculation/FunctionArray.php index c7cc13b45..0692d34a8 100644 --- a/src/PhpSpreadsheet/Calculation/FunctionArray.php +++ b/src/PhpSpreadsheet/Calculation/FunctionArray.php @@ -277,11 +277,23 @@ class FunctionArray extends CalculationBase 'functionCall' => [MathTrig\Ceiling::class, 'math'], 'argumentCount' => '1-3', ], + // pseudo-function to help with Ods + 'CEILING.ODS' => [ + 'category' => Category::CATEGORY_MATH_AND_TRIG, + 'functionCall' => [MathTrig\Ceiling::class, 'mathOds'], + 'argumentCount' => '1-3', + ], 'CEILING.PRECISE' => [ 'category' => Category::CATEGORY_MATH_AND_TRIG, 'functionCall' => [MathTrig\Ceiling::class, 'precise'], 'argumentCount' => '1,2', ], + // pseudo-function implemented in Ods + 'CEILING.XCL' => [ + 'category' => Category::CATEGORY_MATH_AND_TRIG, + 'functionCall' => [MathTrig\Ceiling::class, 'ceiling'], + 'argumentCount' => '2', + ], 'CELL' => [ 'category' => Category::CATEGORY_INFORMATION, 'functionCall' => [Functions::class, 'DUMMY'], @@ -914,11 +926,23 @@ class FunctionArray extends CalculationBase 'functionCall' => [MathTrig\Floor::class, 'math'], 'argumentCount' => '1-3', ], + // pseudo-function to help with Ods + 'FLOOR.ODS' => [ + 'category' => Category::CATEGORY_MATH_AND_TRIG, + 'functionCall' => [MathTrig\Floor::class, 'mathOds'], + 'argumentCount' => '1-3', + ], 'FLOOR.PRECISE' => [ 'category' => Category::CATEGORY_MATH_AND_TRIG, 'functionCall' => [MathTrig\Floor::class, 'precise'], 'argumentCount' => '1-2', ], + // pseudo-function implemented in Ods + 'FLOOR.XCL' => [ + 'category' => Category::CATEGORY_MATH_AND_TRIG, + 'functionCall' => [MathTrig\Floor::class, 'floor'], + 'argumentCount' => '2', + ], 'FORECAST' => [ 'category' => Category::CATEGORY_STATISTICAL, 'functionCall' => [Statistical\Trends::class, 'FORECAST'], diff --git a/src/PhpSpreadsheet/Calculation/MathTrig/Ceiling.php b/src/PhpSpreadsheet/Calculation/MathTrig/Ceiling.php index 365ec2e98..8657086ad 100644 --- a/src/PhpSpreadsheet/Calculation/MathTrig/Ceiling.php +++ b/src/PhpSpreadsheet/Calculation/MathTrig/Ceiling.php @@ -22,12 +22,12 @@ class Ceiling * Excel Function: * CEILING(number[,significance]) * - * @param array|float $number the number you want the ceiling + * @param array|float $number the number you want the ceiling * Or can be an array of values - * @param array|float $significance the multiple to which you want to round + * @param array|float $significance the multiple to which you want to round * Or can be an array of values * - * @return array|float|string Rounded Number, or a string containing an error + * @return array|float|string Rounded Number, or a string containing an error * If an array of numbers is passed as an argument, then the returned result will also be an array * with the same dimensions */ @@ -63,14 +63,14 @@ class Ceiling * Or can be an array of values * @param mixed $significance Significance * Or can be an array of values - * @param array|int $mode direction to round negative numbers + * @param array|int $mode direction to round negative numbers * Or can be an array of values * - * @return array|float|string Rounded Number, or a string containing an error + * @return array|float|string Rounded Number, or a string containing an error * 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 math(mixed $number, mixed $significance = null, $mode = 0): array|string|float + public static function math(mixed $number, mixed $significance = null, $mode = 0, bool $checkSigns = false): array|string|float { if (is_array($number) || is_array($significance) || is_array($mode)) { return self::evaluateArrayArguments([self::class, __FUNCTION__], $number, $significance, $mode); @@ -87,6 +87,11 @@ class Ceiling if (empty($significance * $number)) { return 0.0; } + if ($checkSigns) { + if (($number > 0 && $significance < 0) || ($number < 0 && $significance > 0)) { + return ExcelError::VALUE(); + } + } if (self::ceilingMathTest((float) $significance, (float) $number, (int) $mode)) { return floor($number / $significance) * $significance; } @@ -104,10 +109,10 @@ class Ceiling * * @param mixed $number the number you want to round * Or can be an array of values - * @param array|float $significance the multiple to which you want to round + * @param array|float $significance the multiple to which you want to round * Or can be an array of values * - * @return array|float|string Rounded Number, or a string containing an error + * @return array|float|string Rounded Number, or a string containing an error * If an array of numbers is passed as an argument, then the returned result will also be an array * with the same dimensions */ @@ -132,6 +137,23 @@ class Ceiling return ceil($result) * $significance * (($significance < 0) ? -1 : 1); } + /** + * CEILING.ODS, pseudo-function - CEILING as implemented in ODS. + * + * ODS Function (theoretical): + * CEILING.ODS(number[,significance[,mode]]) + * + * @param mixed $number Number to round + * @param mixed $significance Significance + * @param array|int $mode direction to round negative numbers + * + * @return array|float|string Rounded Number, or a string containing an error + */ + public static function mathOds(mixed $number, mixed $significance = null, $mode = 0): array|string|float + { + return self::math($number, $significance, $mode, true); + } + /** * Let CEILINGMATH complexity pass Scrutinizer. */ diff --git a/src/PhpSpreadsheet/Calculation/MathTrig/Floor.php b/src/PhpSpreadsheet/Calculation/MathTrig/Floor.php index 83cf0515a..320d5cbc0 100644 --- a/src/PhpSpreadsheet/Calculation/MathTrig/Floor.php +++ b/src/PhpSpreadsheet/Calculation/MathTrig/Floor.php @@ -32,7 +32,7 @@ class Floor * @param mixed $significance Expect float. Significance * Or can be an array of values * - * @return array|float|string Rounded Number, or a string containing an error + * @return array|float|string Rounded Number, or a string containing an error * If an array of numbers is passed as an argument, then the returned result will also be an array * with the same dimensions */ @@ -71,11 +71,11 @@ class Floor * @param mixed $mode direction to round negative numbers * Or can be an array of values * - * @return array|float|string Rounded Number, or a string containing an error + * @return array|float|string Rounded Number, or a string containing an error * 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 math(mixed $number, mixed $significance = null, mixed $mode = 0) + public static function math(mixed $number, mixed $significance = null, mixed $mode = 0, bool $checkSigns = false) { if (is_array($number) || is_array($significance) || is_array($mode)) { return self::evaluateArrayArguments([self::class, __FUNCTION__], $number, $significance, $mode); @@ -89,9 +89,34 @@ class Floor return $e->getMessage(); } + if ($checkSigns) { + if (($number > 0 && $significance < 0) || ($number < 0 && $significance > 0)) { + return ExcelError::VALUE(); + } + } + return self::argsOk((float) $number, (float) $significance, (int) $mode); } + /** + * FLOOR.ODS, pseudo-function - FLOOR as implemented in ODS. + * + * Round a number down to the nearest integer or to the nearest multiple of significance. + * + * ODS Function (theoretical): + * FLOOR.ODS(number[,significance[,mode]]) + * + * @param mixed $number Number to round + * @param mixed $significance Significance + * @param array|int $mode direction to round negative numbers + * + * @return array|float|string Rounded Number, or a string containing an error + */ + public static function mathOds(mixed $number, mixed $significance = null, mixed $mode = 0) + { + return self::math($number, $significance, $mode, true); + } + /** * FLOOR.PRECISE. * @@ -100,12 +125,12 @@ class Floor * Excel Function: * FLOOR.PRECISE(number[,significance]) * - * @param array|float $number Number to round + * @param array|float $number Number to round * Or can be an array of values - * @param array|float $significance Significance + * @param array|float $significance Significance * Or can be an array of values * - * @return array|float|string Rounded Number, or a string containing an error + * @return array|float|string Rounded Number, or a string containing an error * If an array of numbers is passed as an argument, then the returned result will also be an array * with the same dimensions */ diff --git a/src/PhpSpreadsheet/Reader/Ods/FormulaTranslator.php b/src/PhpSpreadsheet/Reader/Ods/FormulaTranslator.php index 123f909d3..032c04a6c 100644 --- a/src/PhpSpreadsheet/Reader/Ods/FormulaTranslator.php +++ b/src/PhpSpreadsheet/Reader/Ods/FormulaTranslator.php @@ -2,6 +2,7 @@ namespace PhpOffice\PhpSpreadsheet\Reader\Ods; +use Composer\Pcre\Preg; use PhpOffice\PhpSpreadsheet\Calculation\Calculation; class FormulaTranslator @@ -27,7 +28,7 @@ class FormulaTranslator // Cell range 3-d reference // As we don't support 3-d ranges, we're just going to take a quick and dirty approach // and assume that the second worksheet reference is the same as the first - $excelAddress = (string) preg_replace( + $excelAddress = Preg::replace( [ '/\$?([^\.]+)\.([^\.]+):\$?([^\.]+)\.([^\.]+)/miu', '/\$?([^\.]+)\.([^\.]+):\.([^\.]+)/miu', // Cell range reference in another sheet @@ -62,7 +63,7 @@ class FormulaTranslator // so that conversion isn't done in string values $tKey = $tKey === false; if ($tKey) { - $value = (string) preg_replace( + $value = Preg::replace( [ '/\[\$?([^\.]+)\.([^\.]+):\.([^\.]+)\]/miu', // Cell range reference in another sheet '/\[\$?([^\.]+)\.([^\.]+)\]/miu', // Cell reference in another sheet @@ -103,7 +104,18 @@ class FormulaTranslator Calculation::FORMULA_CLOSE_MATRIX_BRACE ); - $value = (string) preg_replace('/COM\.MICROSOFT\./ui', '', $value); + $value = Preg::replace( + [ + '/\b(?convertCellReferences($formula, $worksheetName); $formula = $this->convertDefinedNames($formula); + $formula = $this->convertFunctionNames($formula); if (!str_starts_with($formula, '=')) { $formula = '=' . $formula; @@ -117,4 +118,22 @@ class Formula return $formula; } + + private function convertFunctionNames(string $formula): string + { + return Preg::replace( + [ + '/\b((CEILING|FLOOR)' + . '([.](MATH|PRECISE))?)\s*[(]/ui', + '/\b(CEILING|FLOOR)[.]XCL\s*[(]/ui', + '/\b(CEILING|FLOOR)[.]ODS\s*[(]/ui', + ], + [ + 'COM.MICROSOFT.$1(', + 'COM.MICROSOFT.$1(', + '$1(', + ], + $formula + ); + } } diff --git a/src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php b/src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php index 31e1e3e0b..80f0636f8 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php @@ -216,6 +216,18 @@ class FunctionPrefix */ public static function addFunctionPrefixStripEquals(string $functionString): string { + $functionString = Preg::replace( + [ + '/\b(CEILING|FLOOR)[.]ODS\s*[(]/', + '/\b(CEILING|FLOOR)[.]XCL\s*[(]/', + ], + [ + '$1.MATH(', + '$1(', + ], + $functionString + ); + return self::addFunctionPrefix(substr($functionString, 1)); } } diff --git a/tests/PhpSpreadsheetTests/Reader/Ods/CeilingFloorTest.php b/tests/PhpSpreadsheetTests/Reader/Ods/CeilingFloorTest.php new file mode 100644 index 000000000..b7af5edc0 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Ods/CeilingFloorTest.php @@ -0,0 +1,91 @@ + '=FLOOR(2.5,1)', + 'A2' => '=FLOOR.MATH(2.5,1)', + 'A3' => '=FLOOR.PRECISE(2.5,1)', + 'A4' => '=FLOOR.ODS(2.5,1,0)', + 'C1' => '=CEILING(2.5,1)', + 'C2' => '=CEILING.MATH(2.5,1)', + 'C3' => '=CEILING.PRECISE(2.5,1)', + 'C4' => '=CEILING.ODS(2.5,1)', + 'E1' => '=FLOOR.ODS(37, -1, 0)', + 'F1' => '=CEILING.ODS(-37.2,1,0)', + ]; + + private const EXPECTED_XLSX = [ + 'A1' => '=FLOOR(2.5,1)', + 'A2' => '=FLOOR.MATH(2.5,1)', + 'A3' => '=FLOOR.PRECISE(2.5,1)', + 'A4' => '=FLOOR.MATH(2.5,1,0)', // note different from ODS + 'C1' => '=CEILING(2.5,1)', + 'C2' => '=CEILING.MATH(2.5,1)', + 'C3' => '=CEILING.PRECISE(2.5,1)', + 'C4' => '=CEILING.MATH(2.5,1)', // different from ODS + 'E1' => '=FLOOR.MATH(37, -1, 0)', // different from ODS + 'F1' => '=CEILING.MATH(-37.2,1,0)', // different from ODS + ]; + + public function testReadAndWriteOds(): void + { + $reader = new OdsReader(); + $spreadsheetOld = $reader->load(self::INFILE); + $oldSheet = $spreadsheetOld->getActiveSheet(); + foreach (self::EXPECTED_ODS as $key => $value) { + self::assertSame( + $value, + $oldSheet->getCell($key)->getValue(), + "Error in cell $key" + ); + } + self::assertSame('#VALUE!', $oldSheet->getCell('E1')->getCalculatedValue()); + self::assertSame('#VALUE!', $oldSheet->getCell('F1')->getCalculatedValue()); + + $spreadsheet = $this->writeAndReload($spreadsheetOld, 'Ods'); + $spreadsheetOld->disconnectWorksheets(); + $sheet = $spreadsheet->getActiveSheet(); + foreach (self::EXPECTED_ODS as $key => $value) { + self::assertSame( + $value, + $sheet->getCell($key)->getValue(), + "Error in cell $key" + ); + } + self::assertSame('#VALUE!', $sheet->getCell('E1')->getCalculatedValue()); + self::assertSame('#VALUE!', $sheet->getCell('F1')->getCalculatedValue()); + + $spreadsheet->disconnectWorksheets(); + } + + public function testReadAndWriteXlsx(): void + { + $reader = new OdsReader(); + $spreadsheetOld = $reader->load(self::INFILE); + + $spreadsheet = $this->writeAndReload($spreadsheetOld, 'Xlsx'); + $spreadsheetOld->disconnectWorksheets(); + $sheet = $spreadsheet->getActiveSheet(); + foreach (self::EXPECTED_XLSX as $key => $value) { + self::assertSame( + $value, + $sheet->getCell($key)->getValue(), + "Error in cell $key" + ); + } + self::assertSame(37.0, $sheet->getCell('E1')->getCalculatedValue()); // different from ODS + self::assertSame(-37.0, $sheet->getCell('F1')->getCalculatedValue()); // different from ODS + + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/data/Reader/Ods/issue.407.ods b/tests/data/Reader/Ods/issue.407.ods new file mode 100644 index 000000000..3f3a05542 Binary files /dev/null and b/tests/data/Reader/Ods/issue.407.ods differ