Skip to content

Commit 39511e0

Browse files
committed
Ods Handling of Ceiling and Floor
Fix #407, which went stale over 7 years ago, and is now reopened. Ods handling of CEILING and FLOOR functions is, apparently by design, subject to interoperability problems with Excel. See [FLOOR](https://wiki.documentfoundation.org/Documentation/Calc_Functions/FLOOR) and [CEILING](https://wiki.documentfoundation.org/Documentation/Calc_Functions/CEILING). Ods implements its own `CEILING.XCL` and `FLOOR.XCL` functions for interoperability purposes. PhpSpreadsheet will implement those, and `CEILING.ODS` and `FLOOR.ODS` psuedo-functions as well; none of these four will be listed in the official Functions documentation. In all of the descriptions below, FLOOR follows CEILING exactly. Ods writes its functions to Xml as follows: - CEILING.XCL becomes COM.MICROSOFT.CEILING - CEILING.MATH becomes COM.MICROSOFT.CEILING.MATH - CEILING.PRECISE becomes COM.MICROSOFT.CEILING.PRECISE - CEILING remains unchanged PhpSpreadsheet Ods Writer will do the same, plus: - CEILING.ODS becomes CEILING PhpSpreadsheet Ods Reader will act as follows: - CEILING (not preceded by 'COM.MICROSOFT.') becomes CEILING.ODS - COM.MICROSOFT.CEILING.MATH becomes CEILING.MATH - COM.MICROSOFT.CEILING.PRECISE becomes CEILING.PRECISE - COM.MICROSOFT.CEILING becomes CEILING PhpSpreadsheet Xlsx Writer will act as follows: - CEILING.ODS becomes CEILING.MATH - CEILING.XCL becomes CEILING PhpSpreadsheet Xls Writer will recognize only the CEILING and FLOOR functions. Note that the only difference between Ods CEILING and CEILING.MATH is that CEILING will return an error if the number and significance operands have opposite signs. PhpSpreadsheet implements this as `#VALUE!`.
1 parent 3922d9a commit 39511e0

File tree

9 files changed

+235
-17
lines changed

9 files changed

+235
-17
lines changed

infra/DocumentGenerator.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99

1010
class DocumentGenerator
1111
{
12+
private const EXCLUDED_FUNCTIONS = [
13+
'CEILING.ODS',
14+
'CEILING.XCL',
15+
'FLOOR.ODS',
16+
'FLOOR.XCL',
17+
];
18+
1219
/**
1320
* @param array<string, array{category: string, functionCall: string|string[], argumentCount: string, passCellReference?: bool, passByReference?: bool[], custom?: bool}> $phpSpreadsheetFunctions
1421
*/
@@ -23,6 +30,9 @@ public static function generateFunctionListByCategory($phpSpreadsheetFunctions):
2330
$result .= self::tableRow($lengths, ['Excel Function', 'PhpSpreadsheet Function']) . "\n";
2431
$result .= self::tableRow($lengths, null) . "\n";
2532
foreach ($phpSpreadsheetFunctions as $excelFunction => $functionInfo) {
33+
if (in_array($excelFunction, self::EXCLUDED_FUNCTIONS, true)) {
34+
continue;
35+
}
2636
if ($category === $functionInfo['category']) {
2737
$phpFunction = self::getPhpSpreadsheetFunctionText($functionInfo['functionCall']);
2838
$result .= self::tableRow($lengths, [$excelFunction, $phpFunction]) . "\n";
@@ -87,6 +97,9 @@ public static function generateFunctionListByName(array $phpSpreadsheetFunctions
8797
$result = "# Function list by name\n";
8898
$lastAlphabet = null;
8999
foreach ($phpSpreadsheetFunctions as $excelFunction => $functionInfo) {
100+
if (in_array($excelFunction, self::EXCLUDED_FUNCTIONS, true)) {
101+
continue;
102+
}
90103
$lengths = [25, 31, 37];
91104
if ($lastAlphabet !== $excelFunction[0]) {
92105
$lastAlphabet = $excelFunction[0];

src/PhpSpreadsheet/Calculation/FunctionArray.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,11 +277,23 @@ class FunctionArray extends CalculationBase
277277
'functionCall' => [MathTrig\Ceiling::class, 'math'],
278278
'argumentCount' => '1-3',
279279
],
280+
// pseudo-function to help with Ods
281+
'CEILING.ODS' => [
282+
'category' => Category::CATEGORY_MATH_AND_TRIG,
283+
'functionCall' => [MathTrig\Ceiling::class, 'mathOds'],
284+
'argumentCount' => '1-3',
285+
],
280286
'CEILING.PRECISE' => [
281287
'category' => Category::CATEGORY_MATH_AND_TRIG,
282288
'functionCall' => [MathTrig\Ceiling::class, 'precise'],
283289
'argumentCount' => '1,2',
284290
],
291+
// pseudo-function implemented in Ods
292+
'CEILING.XCL' => [
293+
'category' => Category::CATEGORY_MATH_AND_TRIG,
294+
'functionCall' => [MathTrig\Ceiling::class, 'ceiling'],
295+
'argumentCount' => '2',
296+
],
285297
'CELL' => [
286298
'category' => Category::CATEGORY_INFORMATION,
287299
'functionCall' => [Functions::class, 'DUMMY'],
@@ -914,11 +926,23 @@ class FunctionArray extends CalculationBase
914926
'functionCall' => [MathTrig\Floor::class, 'math'],
915927
'argumentCount' => '1-3',
916928
],
929+
// pseudo-function to help with Ods
930+
'FLOOR.ODS' => [
931+
'category' => Category::CATEGORY_MATH_AND_TRIG,
932+
'functionCall' => [MathTrig\Floor::class, 'mathOds'],
933+
'argumentCount' => '1-3',
934+
],
917935
'FLOOR.PRECISE' => [
918936
'category' => Category::CATEGORY_MATH_AND_TRIG,
919937
'functionCall' => [MathTrig\Floor::class, 'precise'],
920938
'argumentCount' => '1-2',
921939
],
940+
// pseudo-function implemented in Ods
941+
'FLOOR.XCL' => [
942+
'category' => Category::CATEGORY_MATH_AND_TRIG,
943+
'functionCall' => [MathTrig\Floor::class, 'floor'],
944+
'argumentCount' => '2',
945+
],
922946
'FORECAST' => [
923947
'category' => Category::CATEGORY_STATISTICAL,
924948
'functionCall' => [Statistical\Trends::class, 'FORECAST'],

src/PhpSpreadsheet/Calculation/MathTrig/Ceiling.php

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,12 @@ class Ceiling
2222
* Excel Function:
2323
* CEILING(number[,significance])
2424
*
25-
* @param array|float $number the number you want the ceiling
25+
* @param array<mixed>|float $number the number you want the ceiling
2626
* Or can be an array of values
27-
* @param array|float $significance the multiple to which you want to round
27+
* @param array<mixed>|float $significance the multiple to which you want to round
2828
* Or can be an array of values
2929
*
30-
* @return array|float|string Rounded Number, or a string containing an error
30+
* @return array<mixed>|float|string Rounded Number, or a string containing an error
3131
* If an array of numbers is passed as an argument, then the returned result will also be an array
3232
* with the same dimensions
3333
*/
@@ -63,14 +63,14 @@ public static function ceiling($number, $significance = null)
6363
* Or can be an array of values
6464
* @param mixed $significance Significance
6565
* Or can be an array of values
66-
* @param array|int $mode direction to round negative numbers
66+
* @param array<mixed>|int $mode direction to round negative numbers
6767
* Or can be an array of values
6868
*
69-
* @return array|float|string Rounded Number, or a string containing an error
69+
* @return array<mixed>|float|string Rounded Number, or a string containing an error
7070
* If an array of numbers is passed as an argument, then the returned result will also be an array
7171
* with the same dimensions
7272
*/
73-
public static function math(mixed $number, mixed $significance = null, $mode = 0): array|string|float
73+
public static function math(mixed $number, mixed $significance = null, $mode = 0, bool $checkSigns = false): array|string|float
7474
{
7575
if (is_array($number) || is_array($significance) || is_array($mode)) {
7676
return self::evaluateArrayArguments([self::class, __FUNCTION__], $number, $significance, $mode);
@@ -87,6 +87,11 @@ public static function math(mixed $number, mixed $significance = null, $mode = 0
8787
if (empty($significance * $number)) {
8888
return 0.0;
8989
}
90+
if ($checkSigns) {
91+
if (($number > 0 && $significance < 0) || ($number < 0 && $significance > 0)) {
92+
return ExcelError::VALUE();
93+
}
94+
}
9095
if (self::ceilingMathTest((float) $significance, (float) $number, (int) $mode)) {
9196
return floor($number / $significance) * $significance;
9297
}
@@ -104,10 +109,10 @@ public static function math(mixed $number, mixed $significance = null, $mode = 0
104109
*
105110
* @param mixed $number the number you want to round
106111
* Or can be an array of values
107-
* @param array|float $significance the multiple to which you want to round
112+
* @param array<mixed>|float $significance the multiple to which you want to round
108113
* Or can be an array of values
109114
*
110-
* @return array|float|string Rounded Number, or a string containing an error
115+
* @return array<mixed>|float|string Rounded Number, or a string containing an error
111116
* If an array of numbers is passed as an argument, then the returned result will also be an array
112117
* with the same dimensions
113118
*/
@@ -132,6 +137,23 @@ public static function precise(mixed $number, $significance = 1): array|string|f
132137
return ceil($result) * $significance * (($significance < 0) ? -1 : 1);
133138
}
134139

140+
/**
141+
* CEILING.ODS, pseudo-function - CEILING as implemented in ODS.
142+
*
143+
* ODS Function (theoretical):
144+
* CEILING.ODS(number[,significance[,mode]])
145+
*
146+
* @param mixed $number Number to round
147+
* @param mixed $significance Significance
148+
* @param array<mixed>|int $mode direction to round negative numbers
149+
*
150+
* @return array<mixed>|float|string Rounded Number, or a string containing an error
151+
*/
152+
public static function mathOds(mixed $number, mixed $significance = null, $mode = 0): array|string|float
153+
{
154+
return self::math($number, $significance, $mode, true);
155+
}
156+
135157
/**
136158
* Let CEILINGMATH complexity pass Scrutinizer.
137159
*/

src/PhpSpreadsheet/Calculation/MathTrig/Floor.php

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ private static function floorCheck1Arg(): void
3232
* @param mixed $significance Expect float. Significance
3333
* Or can be an array of values
3434
*
35-
* @return array|float|string Rounded Number, or a string containing an error
35+
* @return array<mixed>|float|string Rounded Number, or a string containing an error
3636
* If an array of numbers is passed as an argument, then the returned result will also be an array
3737
* with the same dimensions
3838
*/
@@ -71,11 +71,11 @@ public static function floor(mixed $number, mixed $significance = null)
7171
* @param mixed $mode direction to round negative numbers
7272
* Or can be an array of values
7373
*
74-
* @return array|float|string Rounded Number, or a string containing an error
74+
* @return array<mixed>|float|string Rounded Number, or a string containing an error
7575
* If an array of numbers is passed as an argument, then the returned result will also be an array
7676
* with the same dimensions
7777
*/
78-
public static function math(mixed $number, mixed $significance = null, mixed $mode = 0)
78+
public static function math(mixed $number, mixed $significance = null, mixed $mode = 0, bool $checkSigns = false)
7979
{
8080
if (is_array($number) || is_array($significance) || is_array($mode)) {
8181
return self::evaluateArrayArguments([self::class, __FUNCTION__], $number, $significance, $mode);
@@ -89,9 +89,34 @@ public static function math(mixed $number, mixed $significance = null, mixed $mo
8989
return $e->getMessage();
9090
}
9191

92+
if ($checkSigns) {
93+
if (($number > 0 && $significance < 0) || ($number < 0 && $significance > 0)) {
94+
return ExcelError::VALUE();
95+
}
96+
}
97+
9298
return self::argsOk((float) $number, (float) $significance, (int) $mode);
9399
}
94100

101+
/**
102+
* FLOOR.ODS, pseudo-function - FLOOR as implemented in ODS.
103+
*
104+
* Round a number down to the nearest integer or to the nearest multiple of significance.
105+
*
106+
* ODS Function (theoretical):
107+
* FLOOR.ODS(number[,significance[,mode]])
108+
*
109+
* @param mixed $number Number to round
110+
* @param mixed $significance Significance
111+
* @param array<mixed>|int $mode direction to round negative numbers
112+
*
113+
* @return array<mixed>|float|string Rounded Number, or a string containing an error
114+
*/
115+
public static function mathOds(mixed $number, mixed $significance = null, mixed $mode = 0)
116+
{
117+
return self::math($number, $significance, $mode, true);
118+
}
119+
95120
/**
96121
* FLOOR.PRECISE.
97122
*
@@ -100,12 +125,12 @@ public static function math(mixed $number, mixed $significance = null, mixed $mo
100125
* Excel Function:
101126
* FLOOR.PRECISE(number[,significance])
102127
*
103-
* @param array|float $number Number to round
128+
* @param array<mixed>|float $number Number to round
104129
* Or can be an array of values
105-
* @param array|float $significance Significance
130+
* @param array<mixed>|float $significance Significance
106131
* Or can be an array of values
107132
*
108-
* @return array|float|string Rounded Number, or a string containing an error
133+
* @return array<mixed>|float|string Rounded Number, or a string containing an error
109134
* If an array of numbers is passed as an argument, then the returned result will also be an array
110135
* with the same dimensions
111136
*/

src/PhpSpreadsheet/Reader/Ods/FormulaTranslator.php

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace PhpOffice\PhpSpreadsheet\Reader\Ods;
44

5+
use Composer\Pcre\Preg;
56
use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
67

78
class FormulaTranslator
@@ -27,7 +28,7 @@ public static function convertToExcelAddressValue(string $openOfficeAddress): st
2728
// Cell range 3-d reference
2829
// As we don't support 3-d ranges, we're just going to take a quick and dirty approach
2930
// and assume that the second worksheet reference is the same as the first
30-
$excelAddress = (string) preg_replace(
31+
$excelAddress = Preg::replace(
3132
[
3233
'/\$?([^\.]+)\.([^\.]+):\$?([^\.]+)\.([^\.]+)/miu',
3334
'/\$?([^\.]+)\.([^\.]+):\.([^\.]+)/miu', // Cell range reference in another sheet
@@ -62,7 +63,7 @@ public static function convertToExcelFormulaValue(string $openOfficeFormula): st
6263
// so that conversion isn't done in string values
6364
$tKey = $tKey === false;
6465
if ($tKey) {
65-
$value = (string) preg_replace(
66+
$value = Preg::replace(
6667
[
6768
'/\[\$?([^\.]+)\.([^\.]+):\.([^\.]+)\]/miu', // Cell range reference in another sheet
6869
'/\[\$?([^\.]+)\.([^\.]+)\]/miu', // Cell reference in another sheet
@@ -103,7 +104,18 @@ public static function convertToExcelFormulaValue(string $openOfficeFormula): st
103104
Calculation::FORMULA_CLOSE_MATRIX_BRACE
104105
);
105106

106-
$value = (string) preg_replace('/COM\.MICROSOFT\./ui', '', $value);
107+
$value = Preg::replace(
108+
[
109+
'/\b(?<!com[.]microsoft[.])'
110+
. '(floor|ceiling)\s*[(]/ui',
111+
'/COM\.MICROSOFT\./ui',
112+
],
113+
[
114+
'$1.ODS(',
115+
'',
116+
],
117+
$value
118+
);
107119
}
108120
}
109121

src/PhpSpreadsheet/Writer/Ods/Formula.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public function convertFormula(string $formula, string $worksheetName = ''): str
2626
{
2727
$formula = $this->convertCellReferences($formula, $worksheetName);
2828
$formula = $this->convertDefinedNames($formula);
29+
$formula = $this->convertFunctionNames($formula);
2930

3031
if (!str_starts_with($formula, '=')) {
3132
$formula = '=' . $formula;
@@ -117,4 +118,22 @@ private function convertCellReferences(string $formula, string $worksheetName):
117118

118119
return $formula;
119120
}
121+
122+
private function convertFunctionNames(string $formula): string
123+
{
124+
return Preg::replace(
125+
[
126+
'/\b((CEILING|FLOOR)'
127+
. '([.](MATH|PRECISE))?)\s*[(]/ui',
128+
'/\b(CEILING|FLOOR)[.]XCL\s*[(]/ui',
129+
'/\b(CEILING|FLOOR)[.]ODS\s*[(]/ui',
130+
],
131+
[
132+
'COM.MICROSOFT.$1(',
133+
'COM.MICROSOFT.$1(',
134+
'$1(',
135+
],
136+
$formula
137+
);
138+
}
120139
}

src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,18 @@ public static function addFunctionPrefix(string $functionString): string
216216
*/
217217
public static function addFunctionPrefixStripEquals(string $functionString): string
218218
{
219+
$functionString = Preg::replace(
220+
[
221+
'/\b(CEILING|FLOOR)[.]ODS\s*[(]/',
222+
'/\b(CEILING|FLOOR)[.]XCL\s*[(]/',
223+
],
224+
[
225+
'$1.MATH(',
226+
'$1(',
227+
],
228+
$functionString
229+
);
230+
219231
return self::addFunctionPrefix(substr($functionString, 1));
220232
}
221233
}

0 commit comments

Comments
 (0)