From d727455173fccf6829b258e215df4a6061489055 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 11 Dec 2025 17:37:17 -0800 Subject: [PATCH] Document Excel's Behavior When Sorting Different DataTypes --- docs/topics/Excel Anomalies.md | 6 +- docs/topics/reading-and-writing-to-file.md | 2 + samples/LookupRef/SortExcel.php | 99 +++++++++++++++++++ .../Calculation/LookupRef/Sort.php | 12 +++ src/PhpSpreadsheet/Helper/Sample.php | 5 +- src/PhpSpreadsheet/Helper/TextGrid.php | 12 +-- .../Helper/TextGridRightAlign.php | 10 ++ .../Helper/TextGridExtended.php | 5 +- .../Helper/TextGridTest.php | 5 +- 9 files changed, 144 insertions(+), 12 deletions(-) create mode 100644 samples/LookupRef/SortExcel.php create mode 100644 src/PhpSpreadsheet/Helper/TextGridRightAlign.php diff --git a/docs/topics/Excel Anomalies.md b/docs/topics/Excel Anomalies.md index d41337a5f..46e3c2a20 100644 --- a/docs/topics/Excel Anomalies.md +++ b/docs/topics/Excel Anomalies.md @@ -43,4 +43,8 @@ Similar fraction formats have inconsistent results in Excel. For example, if a c ## COUNTIF and Text Cells -In Excel, COUNTIF appears to ignore text cells, behavior which doesn't seem to be documented anywhere. See [this issue](https://github.com/PHPOffice/PhpSpreadsheet/issues/3802), which remains open because, in the absence of usable documentation, we aren't sure how to handle things. \ No newline at end of file +In Excel, COUNTIF appears to ignore text cells, behavior which doesn't seem to be documented anywhere. See [this issue](https://github.com/PHPOffice/PhpSpreadsheet/issues/3802), which remains open because, in the absence of usable documentation, we aren't sure how to handle things. + +## SORT on Different DataTypes + +Excel appears to sort so that numbers are lowest in sort order, strings are next, booleans are next (LibreOffice treats booleans as ints), and null is highest (and converted to int 0). In addition, if your sort includes a numeric string with a leading plus or minus sign, the plus sign will be considered part of the string (so that `"+1"` will sort before `"0"`), but the minus sign will be ignored (so that `"-3"` will sort between `"25"` and `"40"`). There might be nuances I haven't thought of yet. PhpSpreadsheet will not necessarily duplicate Excel's behavior. The best advice we can offer is to make sure that arrays you wish to sort consist of a single datatype, and don't contain numeric strings. Sample samples/LookupRef/SortExcel is added to give an idea of how you might emulate Excel's behavior. I am not yet convinced that there is a use case for adding it as a class member in the src tree. diff --git a/docs/topics/reading-and-writing-to-file.md b/docs/topics/reading-and-writing-to-file.md index 49845e993..1242b7921 100644 --- a/docs/topics/reading-and-writing-to-file.md +++ b/docs/topics/reading-and-writing-to-file.md @@ -1207,6 +1207,8 @@ Code like the following can be used: // rowDividers: true, // rowHeaders: false, // columnHeaders: false, + // Starting with release 5.4: + // numbersRight: TextGridRightAlign::numeric, ); $result = $textGrid->render(); ``` diff --git a/samples/LookupRef/SortExcel.php b/samples/LookupRef/SortExcel.php new file mode 100644 index 000000000..f5d749972 --- /dev/null +++ b/samples/LookupRef/SortExcel.php @@ -0,0 +1,99 @@ + $array + */ + public static function sortArray(array &$array): void + { + usort($array, self::cmp(...)); + $j = count($array); + while ($j > 0) { + --$j; + if ($array[$j] !== null) { + break; + } + $array[$j] = 0; + } + } +} + +require __DIR__ . '/../Header.php'; +/** @var Sample $helper */ +$helper->log('Emulating how Excel sorts different DataTypes'); + +/** @param array $original */ +function displaySorted(array $original, Sample $helper): void +{ + $sorted = $original; + SortExcel::sortArray($sorted); + $outArray = [['Original', 'Sorted']]; + $count = count($original); + for ($i = 0; $i < $count; ++$i) { + $outArray[] = [$original[$i], $sorted[$i]]; + } + $helper->displayGrid($outArray, TextGridRightAlign::floatOrInt); +} + +$helper->log('First example'); +$original = ['-3', '40', 'A', 'B', true, false, '+3', '1', '10', '2', '25', 1, 0, -1]; +displaySorted($original, $helper); + +$helper->log('Second example'); +$original = ['a', 'A', null, 'x', 'X', true, false, -3, 1]; +displaySorted($original, $helper); diff --git a/src/PhpSpreadsheet/Calculation/LookupRef/Sort.php b/src/PhpSpreadsheet/Calculation/LookupRef/Sort.php index bb0c01d73..e013da79c 100644 --- a/src/PhpSpreadsheet/Calculation/LookupRef/Sort.php +++ b/src/PhpSpreadsheet/Calculation/LookupRef/Sort.php @@ -19,6 +19,12 @@ class Sort extends LookupRefValidations * The returned array is the same shape as the provided array argument. * Both $sortIndex and $sortOrder can be arrays, to provide multi-level sorting. * + * NOTE: If $sortArray contains a mixture of data types + * (string/int/bool), the results may be unexpected. + * This is also true if the array consists of string + * representations of numbers, especially if there are + * both positive and negative numbers in the mix. + * * @param mixed $sortArray The range of cells being sorted * @param mixed $sortIndex The column or row number within the sortArray to sort on * @param mixed $sortOrder Flag indicating whether to sort ascending or descending @@ -77,6 +83,12 @@ class Sort extends LookupRefValidations * My interpretation of this is that by_array must be an * array which contains exactly one row. * + * NOTE: If the "byArray" contains a mixture of data types + * (string/int/bool), the results may be unexpected. + * This is also true if the array consists of string + * representations of numbers, especially if there are + * both positive and negative numbers in the mix. + * * @param mixed $sortArray The range of cells being sorted * @param mixed $args * At least one additional argument must be provided, The vector or range to sort on diff --git a/src/PhpSpreadsheet/Helper/Sample.php b/src/PhpSpreadsheet/Helper/Sample.php index 5933f7469..b1283bb0b 100644 --- a/src/PhpSpreadsheet/Helper/Sample.php +++ b/src/PhpSpreadsheet/Helper/Sample.php @@ -242,9 +242,12 @@ class Sample } /** @param mixed[][] $matrix */ - public function displayGrid(array $matrix, ?bool $numbersRight = null): void + public function displayGrid(array $matrix, null|bool|TextGridRightAlign $numbersRight = null): void { $renderer = new TextGrid($matrix, $this->isCli()); + if (is_bool($numbersRight)) { + $numbersRight = $numbersRight ? TextGridRightAlign::numeric : TextGridRightAlign::none; + } if ($numbersRight !== null) { $renderer->setNumbersRight($numbersRight); } diff --git a/src/PhpSpreadsheet/Helper/TextGrid.php b/src/PhpSpreadsheet/Helper/TextGrid.php index d9b78a097..cd6fb2f21 100644 --- a/src/PhpSpreadsheet/Helper/TextGrid.php +++ b/src/PhpSpreadsheet/Helper/TextGrid.php @@ -25,10 +25,10 @@ class TextGrid protected bool $columnHeaders = true; - protected bool $numbersRight = false; + protected TextGridRightAlign $numbersRight = TextGridRightAlign::none; /** @param mixed[][] $matrix */ - public function __construct(array $matrix, bool $isCli = true, bool $rowDividers = false, bool $rowHeaders = true, bool $columnHeaders = true, bool $numbersRight = false) + public function __construct(array $matrix, bool $isCli = true, bool $rowDividers = false, bool $rowHeaders = true, bool $columnHeaders = true, TextGridRightAlign $numbersRight = TextGridRightAlign::none) { $this->rows = array_keys($matrix); $this->columns = array_keys($matrix[$this->rows[0]]); @@ -49,7 +49,7 @@ class TextGrid $this->numbersRight = $numbersRight; } - public function setNumbersRight(bool $numbersRight): void + public function setNumbersRight(TextGridRightAlign $numbersRight): void { $this->numbersRight = $numbersRight; } @@ -100,7 +100,7 @@ class TextGrid $valueForLength = $this->getString($cell); $displayCell = $this->isCli ? $valueForLength : htmlentities($valueForLength); $this->gridDisplay .= '| '; - if ($this->rightAlign($displayCell)) { + if ($this->rightAlign($displayCell, $cell)) { $this->gridDisplay .= str_repeat(' ', $columnWidths[$column] - $this->strlen($valueForLength)) . $displayCell . ' '; } else { $this->gridDisplay .= $displayCell . str_repeat(' ', $columnWidths[$column] - $this->strlen($valueForLength) + 1); @@ -108,9 +108,9 @@ class TextGrid } } - protected function rightAlign(string $displayCell): bool + protected function rightAlign(string $displayCell, mixed $cell = null): bool { - return $this->numbersRight && is_numeric($displayCell); + return ($this->numbersRight === TextGridRightAlign::numeric && is_numeric($displayCell)) || ($this->numbersRight === TextGridRightAlign::floatOrInt && (is_int($cell) || is_float($cell))); } /** @param int[] $columnWidths */ diff --git a/src/PhpSpreadsheet/Helper/TextGridRightAlign.php b/src/PhpSpreadsheet/Helper/TextGridRightAlign.php new file mode 100644 index 000000000..2921e5cd6 --- /dev/null +++ b/src/PhpSpreadsheet/Helper/TextGridRightAlign.php @@ -0,0 +1,10 @@ +numbersRight && preg_match('/^[-+$,.0-9]+$/', $displayCell); + return is_int($cell) || is_float($cell) || ($this->numbersRight === TextGridRightAlign::numeric && preg_match('/^[-+$,.0-9]+$/', $displayCell)); } } diff --git a/tests/PhpSpreadsheetTests/Helper/TextGridTest.php b/tests/PhpSpreadsheetTests/Helper/TextGridTest.php index bf12aefea..0a48f8d7e 100644 --- a/tests/PhpSpreadsheetTests/Helper/TextGridTest.php +++ b/tests/PhpSpreadsheetTests/Helper/TextGridTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace PhpOffice\PhpSpreadsheetTests\Helper; use PhpOffice\PhpSpreadsheet\Helper\TextGrid; +use PhpOffice\PhpSpreadsheet\Helper\TextGridRightAlign; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -215,7 +216,7 @@ class TextGridTest extends TestCase rowDividers: false, rowHeaders: false, columnHeaders: false, - numbersRight: true, + numbersRight: TextGridRightAlign::numeric, ); $expected = [ '+-----+-------+--------------+', @@ -251,7 +252,7 @@ class TextGridTest extends TestCase rowHeaders: false, columnHeaders: false, ); - $textGrid->setNumbersRight(true); + $textGrid->setNumbersRight(TextGridRightAlign::numeric); $expected = [ '+-----+-------+--------------+', '| 0 | 1.00 | $1,234.56 |',