From 086ee2c750b881d3a6934ed3db451ce5327a8ade Mon Sep 17 00:00:00 2001 From: ybai08 <271969568+ybai08@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:13:22 -0500 Subject: [PATCH] Write Integers With a Fraction Format As Excel Does Fix #3625. With a fraction format, PhpSpreadsheet wrote an integer as the integer alone, so 1 formatted with `0/0` was `1`. Excel writes `1/1`: a format with no integer part always shows a fraction. Excel also shows the fraction when the last numerator placeholder is 0, so 1 formatted with `0 0/0` is `1 0/1`; with a `?` or `#` numerator placeholder the fraction is left out, as before. The `#` and `0` placeholders were replaced by `?` before FractionFormatter saw the format, so the replacement moves into FractionFormatter, which first notes whether the numerator ends in 0. --- CHANGELOG.md | 1 + .../Style/NumberFormat/FractionFormatter.php | 44 +++++++++++----- .../Style/NumberFormat/NumberFormatter.php | 3 -- tests/data/Style/NumberFormatFractions.php | 51 +++++++++++++++++++ 4 files changed, 82 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b73bc918f..77165f73a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Some earlier branches remain supported and security fixes are applied to them; i - Xls Writer normalizes color strings to upper case and picks the next custom palette index from the highest index in use, so a color can no longer overwrite the palette slot of an earlier color (e.g. a font rendering in a fill color). [Issue #5019](https://github.com/PHPOffice/PhpSpreadsheet/issues/5019) [PR #5020](https://github.com/PHPOffice/PhpSpreadsheet/pull/5020) - Ods Reader reads the hyperlink LibreOffice keeps in the style of a number, boolean, date or formula cell (`style:hyperlink` in `style:table-cell-properties`), as it writes a link from a Xlsx file. [Issue #5017](https://github.com/PHPOffice/PhpSpreadsheet/issues/5017) [PR #5018](https://github.com/PHPOffice/PhpSpreadsheet/pull/5018) - Minor cleanup in Ods Reader to fix test which was not working in Windows. [PR #5052](https://github.com/PHPOffice/PhpSpreadsheet/pull/5052) +- A fraction number format writes an integer as a fraction when the format has no integer part, so 1 is `1/1` with `0/0`, and when the last numerator placeholder is 0, so 1 is `1 0/1` with `0 0/0`, as Excel does. [Issue #3625](https://github.com/PHPOffice/PhpSpreadsheet/issues/3625) [PR #5060](https://github.com/PHPOffice/PhpSpreadsheet/pull/5060) ## 2026-09-15 - 5.10.0 diff --git a/src/PhpSpreadsheet/Style/NumberFormat/FractionFormatter.php b/src/PhpSpreadsheet/Style/NumberFormat/FractionFormatter.php index 7affc2151f..f9aa8b106e 100644 --- a/src/PhpSpreadsheet/Style/NumberFormat/FractionFormatter.php +++ b/src/PhpSpreadsheet/Style/NumberFormat/FractionFormatter.php @@ -9,6 +9,11 @@ class FractionFormatter extends BaseFormatter /** @param null|bool|float|int|string $value value to be formatted */ public static function format(mixed $value, string $format): string { + // A 0 placeholder for the last digit of the numerator shows a numerator of 0 + $showZeroNumerator = str_contains($format, '0/'); + // It's a dirty hack; but replace # and 0 digit placeholders with ? + $format = (string) preg_replace('/[#0]+\//', '?/', $format); + $format = (string) preg_replace('/\/[#0]+/', '/?', $format); $format = self::stripQuotes($format); $value = (float) $value; $absValue = abs($value); @@ -17,25 +22,36 @@ public static function format(mixed $value, string $format): string $integerPart = floor($absValue); - $decimalPart = self::getDecimal((string) $absValue); - if ($decimalPart === '0') { - return "{$sign}{$integerPart}"; - } - $decimalLength = strlen($decimalPart); - $decimalDivisor = 10 ** $decimalLength; - preg_match('/(#?.*\?)\/(\?+|\d+)/', $format, $matches); $formatIntegerPart = $matches[1] ?? '0'; + $fixedDivisor = (isset($matches[2]) && is_numeric($matches[2])) ? (int) $matches[2] : null; + $isVulgar = !str_contains($formatIntegerPart, '0') + && !str_contains($formatIntegerPart, '#') + && !str_starts_with($formatIntegerPart, '? ?'); - if (isset($matches[2]) && is_numeric($matches[2])) { - $fractionDivisor = 100 / (int) $matches[2]; + $decimalPart = self::getDecimal((string) $absValue); + if ($decimalPart === '0') { + // An integer is written as a fraction when the format has no integer part, + // or when the numerator shows 0; otherwise the fraction is left out + if (!$isVulgar && !$showZeroNumerator) { + return "{$sign}{$integerPart}"; + } + $adjustedDecimalPart = 0; + $adjustedDecimalDivisor = $fixedDivisor ?? 1; } else { - /** @var float $fractionDivisor */ - $fractionDivisor = MathTrig\Gcd::evaluate((int) $decimalPart, $decimalDivisor); - } + $decimalLength = strlen($decimalPart); + $decimalDivisor = 10 ** $decimalLength; + + if ($fixedDivisor !== null) { + $fractionDivisor = 100 / $fixedDivisor; + } else { + /** @var float $fractionDivisor */ + $fractionDivisor = MathTrig\Gcd::evaluate((int) $decimalPart, $decimalDivisor); + } - $adjustedDecimalPart = (int) round((int) $decimalPart / $fractionDivisor, 0); - $adjustedDecimalDivisor = $decimalDivisor / $fractionDivisor; + $adjustedDecimalPart = (int) round((int) $decimalPart / $fractionDivisor, 0); + $adjustedDecimalDivisor = $decimalDivisor / $fractionDivisor; + } if ((str_contains($formatIntegerPart, '0'))) { return "{$sign}{$integerPart} {$adjustedDecimalPart}/{$adjustedDecimalDivisor}"; diff --git a/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php b/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php index 22b02b126e..a6240bc1eb 100644 --- a/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php +++ b/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php @@ -208,9 +208,6 @@ public static function format(mixed $value, string $format): string $scale = self::scaleThousandsMillions($format); if (preg_match('/[#\?0]?.*[#\?0]\/(\?+|\d+|#)/', $format)) { - // It's a dirty hack; but replace # and 0 digit placeholders with ? - $format = (string) preg_replace('/[#0]+\//', '?/', $format); - $format = (string) preg_replace('/\/[#0]+/', '/?', $format); $value = FractionFormatter::format($value, $format); } else { // Handle the number itself diff --git a/tests/data/Style/NumberFormatFractions.php b/tests/data/Style/NumberFormatFractions.php index 4daafca2ac..d2b0862e71 100644 --- a/tests/data/Style/NumberFormatFractions.php +++ b/tests/data/Style/NumberFormatFractions.php @@ -143,4 +143,55 @@ 2.65, '# ???/12', ], + // Integer values (issue 3625) + 'integer in vulgar fraction' => [ + '1/1', + 1, + '0/0', + ], + 'integer in vulgar fraction with ? placeholders' => [ + '1/1', + 1, + '??/???', + ], + 'larger integer in vulgar fraction' => [ + '12/1', + 12, + '?/?', + ], + 'negative integer in vulgar fraction' => [ + '-3/1', + -3, + '?/?', + ], + 'zero in vulgar fraction' => [ + '0/1', + 0, + '0/0', + ], + 'integer in vulgar fraction with fixed denominator' => [ + '500/100', + 5, + '??/100', + ], + 'integer with 0 numerator placeholder' => [ + '1 0/1', + 1, + '0 0/0', + ], + 'integer with 0 numerator placeholder and fixed denominator' => [ + '5 0/4', + 5, + '0 0/4', + ], + 'integer with ? numerator placeholder' => [ + '1', + 1, + '? ??/???', + ], + 'integer with # whole part and ? numerator placeholder' => [ + '7', + 7, + '# ?/?', + ], ];