Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Fixed

- Fixed the `MOD` function returning a remainder with the sign of the dividend instead of the sign of the divisor, which made the results differ from Excel and Google Sheets for arguments with opposite signs (e.g. `=MOD(-3, 12)` now returns `9` instead of `-3`). [#1747](https://github.com/handsontable/hyperformula/issues/1747)

## [3.4.0] - 2026-08-10

### Added
Expand Down
2 changes: 1 addition & 1 deletion DEV_DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ It does **not** turn ordinary English into identifiers. A parameter's own descri

Note what the drift warning does **not** cover: **optionality is not cross-checked.** The catalogue authors no optionality of its own — a parameter's `optional` flag is derived entirely from `optionalArg`/`defaultValue` in `implementedFunctions` — so a description that calls an argument optional can sit next to `optional: false` with nothing failing. When a function accepts a call that arity alone does not express (`SHEET()`, `ROW()`, and anything else served by `runFunctionWithReferenceArgument`'s zero-argument path), the plugin must declare `optionalArg: true` explicitly, or the public API will advertise the argument as required. `ROW`, `COLUMN`, `SHEET` and `SHEETS` all declare it; `ISFORMULA` takes the same path and correctly does not, because its zero-argument call is an error rather than a shorthand.

Descriptions must describe **HyperFormula's** behaviour, not Excel's. Much of the catalogue was seeded from a hand-written page that documented Excel, and HyperFormula deliberately deviates in places (`INT` truncates toward zero, `MOD` takes the sign of the dividend, `ISEVEN`/`ISODD` do not truncate, `CEILING.MATH`/`FLOOR.MATH` honour only `mode` = 1). Verify a claim against the implementation before authoring it, and record any deviation in [the list of differences](docs/guide/list-of-differences.md).
Descriptions must describe **HyperFormula's** behaviour, not Excel's. Much of the catalogue was seeded from a hand-written page that documented Excel, and HyperFormula deliberately deviates in places (`INT` truncates toward zero, `ISEVEN`/`ISODD` do not truncate, `CEILING.MATH`/`FLOOR.MATH` honour only `mode` = 1). Verify a claim against the implementation before authoring it, and record any deviation in [the list of differences](docs/guide/list-of-differences.md).

## Internationalization and function translations

Expand Down
2 changes: 0 additions & 2 deletions docs/guide/list-of-differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,6 @@ To remove the differences, create [custom implementations](custom-functions.md)
| ADDRESS | =ADDRESS(1,1,4, TRUE(), "") | !A1 | ''!A1 | !A1 |
| SEQUENCE | =SEQUENCE(0) | VALUE | N/A | CALC |
| INT | =INT(-8.9) | -8 | -9 | -9 |
| MOD | =MOD(-10, 3) | -1 | 2 | 2 |
| ISEVEN | =ISEVEN(2.5) | FALSE | TRUE | TRUE |
| ISODD | =ISODD(3.5) | FALSE | TRUE | TRUE |
| CEILING.MATH | =CEILING.MATH(-4.3, 2, 2) | -4 | -6 | -6 |
Expand All @@ -126,6 +125,5 @@ To remove the differences, create [custom implementations](custom-functions.md)
A few of the rows above share a root cause worth stating once:

- **Rounding toward zero, not down.** `INT` discards the fractional part rather than rounding toward negative infinity, so it differs from Excel and Google Sheets for negative input only. `ROUNDDOWN`/`ROUNDUP` are unaffected — they are defined in terms of zero in all three.
- **`MOD` takes the sign of the dividend.** Excel and Google Sheets return a result with the sign of the *divisor*.
- **`ISEVEN`/`ISODD` do not truncate.** They test the remainder of the value as given, so a value with a fractional part returns `FALSE` from *both*. Excel and Google Sheets truncate to an integer first, so exactly one of the two is always `TRUE`.
- **`CEILING.MATH`/`FLOOR.MATH` honour only `mode` = 1.** Excel and Google Sheets switch the negative-number rounding direction for any non-zero `mode`.
Original file line number Diff line number Diff line change
Expand Up @@ -278,8 +278,8 @@ export const MATH_AND_TRIGONOMETRY_DOCS: Record<string, FunctionDoc> = {
},
MOD: {
category: 'Math and trigonometry',
shortDescription: 'Returns the remainder when one number is divided by another.',
parameters: [{name: 'dividend', description: 'The number to be divided.'}, {name: 'divisor', description: 'The non-zero number to divide by. The result has the same sign as the dividend.'}],
shortDescription: 'Returns the remainder when one number is divided by another. The result has the same sign as divisor.',
parameters: [{name: 'dividend', description: 'The number to be divided.'}, {name: 'divisor', description: 'The non-zero number to divide by.'}],
documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html',
examples: ['=MOD(10, 3)', '=MOD(-7, 2)'],
},
Expand Down
39 changes: 37 additions & 2 deletions src/interpreter/plugin/ModuloPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,44 @@ export class ModuloPlugin extends FunctionPlugin implements FunctionPluginTypech
return this.runFunction(ast.args, state, this.metadata('MOD'), (dividend: number, divisor: number) => {
if (divisor === 0) {
return new CellError(ErrorType.DIV_BY_ZERO)
} else {
return dividend % divisor
}

return flooredRemainder(dividend, divisor)
})
}
}

/**
* Computes the remainder of a division, taking the sign of the divisor.
*
* This is the floored remainder, i.e. the one left by a division rounded towards negative infinity.
* It is what Excel, Google Sheets and the OpenDocument specification define MOD to return. The `%`
* operator computes the truncated remainder instead, which takes the sign of the dividend: the two
* agree whenever the arguments share a sign, and differ by exactly one divisor when they do not.
*
* Correcting the remainder given by `%` is more verbose than the two textbook one-liners, but neither
* of those is accurate enough for a calculation engine:
* - `dividend - divisor * Math.floor(dividend / divisor)` rounds twice, and the multiplication scales
* the error of the division back up. It returns 0 instead of 2 for a dividend of 1e308 and a divisor
* of 3, and overflows to -Infinity for a dividend of Number.MAX_VALUE.
* - `((dividend % divisor) + divisor) % divisor` loses the remainder entirely when it is negligible
* next to the divisor, returning 0 instead of 1e-20 for a divisor of 3, and overflows to NaN when the
* intermediate sum exceeds Number.MAX_VALUE.
*
* `%` on its own is exact for IEEE 754 doubles, so applying the correction only where it is needed
* keeps every already-correct result untouched.
*
* @param {number} dividend - the number being divided
* @param {number} divisor - the number to divide by, must not be 0
*/
function flooredRemainder(dividend: number, divisor: number): number {
const truncatedRemainder = dividend % divisor
const isDivisibleExactly = truncatedRemainder === 0
const hasSignOfDivisor = (truncatedRemainder < 0) === (divisor < 0)

if (isDivisibleExactly || hasSignOfDivisor) {
return truncatedRemainder
}

return truncatedRemainder + divisor
}
Loading