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
3 changes: 3 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion packages/css-calc/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@
- Fixed `round(line-width, ...)` to choose the non-zero candidate multiple when `A` is negative
- Fixed `round(down/up, ...)` with a negative step to choose the correct candidate multiple
- Fixed `log(A, 0)` to return `NaN` as specified (only `B` values between 0 and 1, or greater than 1, are valid)
- Fixed `log(1, B)` to return `0⁺` as specified
- Fixed `log(1, B)` to return `0⁺` as specified, except when `B` is `NaN` (NaN stays infectious, so `log(1, NaN)` is now `NaN`)
- Fixed `random()` to not mutate the caller's `options` object
- Fixed `random(fixed <number>, ...)` to clamp the value to the highest representable value less than 1
- Fixed `random()` to treat `max < min` as `max = min` instead of swapping the arguments
- Fixed `random()` to not return an unreachable `max` when a `step` is given
- Fixed `random()` to return `A` (the minimum) when `A` is infinite, instead of `NaN`
- Fix infectious `NaN`
- Fixed the `precision` option to not round values that serialize in scientific notation
(e.g. `calc(1e-10 * 1e-10)` was rounded to `0`, it is now left untouched)
- Fixed `random()` to not round values that serialize in scientific notation
(e.g. `random(fixed 0.5, 1e-20, 1e-10)` returned `0`)
- Updated [`@csstools/css-tokenizer`](https://github.com/csstools/postcss-plugins/tree/main/packages/css-tokenizer) to [`4.0.1`](https://github.com/csstools/postcss-plugins/tree/main/packages/css-tokenizer/CHANGELOG.md#401) (patch)

### 3.4.0
Expand Down
5 changes: 5 additions & 0 deletions packages/css-calc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,12 @@ console.log(calcResultStr);

#### `precision` :

The `precision` option is a number of decimals.
The default precision is fairly high.
It aims to be high enough to make rounding unnoticeable in the browser.

Values that serialize in scientific notation are left untouched.

You can set it to a lower number to suit your needs.

```mjs
Expand All @@ -76,6 +79,8 @@ import { calc } from '@csstools/css-calc';
console.log(calc('calc(1 / 3)', { precision: 1 }));
// '0.33'
console.log(calc('calc(1 / 3)', { precision: 2 }));
// '1.0000000000000001e-20'
console.log(calc('calc(1e-10 * 1e-10)'));
```

#### `globals` :
Expand Down
2 changes: 1 addition & 1 deletion packages/css-calc/dist/index.mjs

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion packages/css-calc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,17 @@
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.1"
},
"devDependencies": {
"puppeteer": "^25.7.0"
},
"scripts": {
"build": "rollup -c ../../rollup/default.mjs",
"docs": "node ../../.github/bin/generate-docs/api-documenter.mjs",
"lint": "node ../../.github/bin/format-package-json.mjs",
"prepublishOnly": "npm run build && npm run test",
"stryker": "stryker run --logLevel error",
"test": "node --test ./test/test.mjs ./test/_import.mjs ./test/_require.cjs"
"test": "node --test ./test/test.mjs ./test/_import.mjs ./test/_require.cjs ./test/browser/_browser.mjs",
"test:browser": "BROWSER_TESTS=true node --test ./test/browser/_browser.mjs"
},
"homepage": "https://github.com/csstools/postcss-plugins/tree/main/packages/css-calc#readme",
"repository": {
Expand Down
5 changes: 5 additions & 0 deletions packages/css-calc/src/functions/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export function solveLog(logNode: FunctionNode, solvedNodes: Array<ComponentValu
return -1;
}

// NaN is infectious, forcing the function to return NaN if any argument calculation is NaN.
if (Number.isNaN(aToken[4].value) || Number.isNaN(bToken[4].value)) {
return numberToCalculation(logNode, Number.NaN);
}

// https://drafts.csswg.org/css-values-4/#exponent-infinities
// If B is 1 or negative, the result is NaN.
// B values between 0 and 1 (exclusive), or greater than 1, are valid.
Expand Down
5 changes: 3 additions & 2 deletions packages/css-calc/src/functions/random.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { FunctionNode, TokenNode } from '@csstools/css-parser-algorithms';
import { convertUnit } from '../unit-conversions';
import { resultToCalculation } from './result-to-calculation';
import { twoOfSameNumeric } from '../util/kind-of-number';
import { roundToPrecision } from '../util/precision';
import type { CSSToken} from '@csstools/css-tokenizer';
import { isTokenNumeric } from '@csstools/css-tokenizer';
import type { conversionOptions } from '../options';
Expand Down Expand Up @@ -144,15 +145,15 @@ export function solveRandom(randomNode: FunctionNode, randomValueSharing: Random
return resultToCalculation(
randomNode,
aToken,
Number(value.toFixed(5))
roundToPrecision(value, 5)
);
}

const randomValue = rnd();
return resultToCalculation(
randomNode,
aToken,
Number(((randomValue * (max - min)) + min).toFixed(5))
roundToPrecision(((randomValue * (max - min)) + min), 5)
);
}

Expand Down
34 changes: 32 additions & 2 deletions packages/css-calc/src/util/precision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ export function patchPrecision(x: TokenNode | FunctionNode | -1, precision = 13)
return x;
}

if (Number.isInteger(token[4].value)) {
if (shouldSkipPrecisionRounding(token[4].value)) {
return x;
}

const result = Number(token[4].value.toFixed(precision)).toString();
const result = roundToPrecision(token[4].value, precision).toString();

if (isTokenNumber(token)) {
token[1] = result;
} else if (isTokenPercentage(token)) {
Expand All @@ -35,3 +36,32 @@ export function patchPrecision(x: TokenNode | FunctionNode | -1, precision = 13)

return x;
}

/**
* Returns `true` when rounding a value to a number of decimals can not change it.
* Non-finite values, zero, integers and values that serialize in scientific
* notation are left untouched.
*
* Rounding scientific notation values would destroy them (e.g. `1e-20` -> `0`).
*/
function shouldSkipPrecisionRounding(value: number): boolean {
if (!Number.isFinite(value) || value === 0) {
return true;
}

if (Number.isInteger(value)) {
return true;
}

const serialized = value.toString();
return serialized.includes('e') || serialized.includes('E');
}

export function roundToPrecision(value: number, precision = 13): number {
if (shouldSkipPrecisionRounding(value)) {
return value;
}

// Otherwise round to a number of decimals.
return Number(value.toFixed(precision));
}
1 change: 1 addition & 0 deletions packages/css-calc/test/additional/index.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import './mod-rem-infinity.mjs';
import './precision.mjs';
import './random.mjs';
import './sign-abs-equivalent.mjs';
import './tan-asymptotes.mjs';
Expand Down
51 changes: 51 additions & 0 deletions packages/css-calc/test/additional/precision.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { calc } from '@csstools/css-calc';
import assert from 'node:assert';

// Values that serialize without scientific notation are rounded to `precision`
// decimal places.
assert.strictEqual(
calc('calc(1 / 3)'),
'0.3333333333333',
);

assert.strictEqual(
calc('calc(1 / 3)', { precision: 5 }),
'0.33333',
);

assert.strictEqual(
calc('calc(0.1 + 0.2)'),
'0.3',
);

// Values that serialize in scientific notation are left untouched.
// Reducing them to a number of decimals would destroy them (e.g. `1e-20` -> `0`).
assert.strictEqual(
calc('calc(1e-10 * 1e-10)'),
'1.0000000000000001e-20',
);

assert.strictEqual(
calc('calc(1e-20 * 1)'),
'1e-20',
);

assert.strictEqual(
calc('calc(1e-300 * 1)'),
'1e-300',
);

assert.strictEqual(
calc('calc(1e300 * 1)'),
'1e+300',
);

assert.strictEqual(
calc('calc(1e21 * 1)'),
'1e+21',
);

assert.strictEqual(
calc('calc(1e-10 * 1e-10)', { precision: 5 }),
'1.0000000000000001e-20',
);
22 changes: 22 additions & 0 deletions packages/css-calc/test/additional/random.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,28 @@ assert.strictEqual(
'500px',
);

// Values that serialize in scientific notation are left untouched.
// Rounding them to a number of decimals would destroy them (e.g. `1e-20` -> `0`).
assert.strictEqual(
calc('random(fixed 0.5, 1e-20, 1e-10)'),
'5.0000000005e-11',
);

assert.strictEqual(
calc('random(fixed 0.5, 1e-20, 1e-10, 1e-30)'),
'5.0000000005e-11',
);

assert.strictEqual(
calc('random(fixed 0, 1e-20, 1e-10)'),
'1e-20',
);

assert.strictEqual(
calc('random(fixed 1, 1e-20, 1e-10)'),
'9.999999999999999e-11',
);

assert.strictEqual(
calc('random(100px, 500px)', { randomCaching: { documentID: 'a', elementID: 'b', propertyName: 'c', propertyN: 0 } }),
'494.67561px',
Expand Down
99 changes: 99 additions & 0 deletions packages/css-calc/test/browser/_browser.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>css-calc browser tests</title>
<link rel="help" href="https://drafts.csswg.org/css-values-4/">
<link rel="help" href="https://drafts.csswg.org/css-values-5/">
<style>
html {
font-size: 16px;
}

#container {
width: 100px;
height: 100px;
}

#target {
position: relative;
font-size: 16px;
width: 10px;
height: 10px;
}
</style>
</head>
<body>
<div id="container">
<div id="target"></div>
</div>

<script>
/* global getComputedStyle */
const target = document.getElementById('target');

// Sets a value on the target and returns both the specified serialization
// and the computed value.
//
// `getComputedStyle` collapses `calc(NaN * 1px)` to `0px`, so `NaN` can
// not be detected from the computed value alone. Use `observeNaN` for
// that; `observe` is used for value comparisons.
self.observe = function observe(property, value) {
target.removeAttribute('style');
target.style.setProperty(property, value);

const specified = target.style.getPropertyValue(property);
const computed = specified === '' ? '' : getComputedStyle(target).getPropertyValue(property);

return { specified, computed };
};

// Determines the sign of the zero value of an arbitrary expression.
//
// `1 / sign(expr)` is `+infinity` for `0` and `-infinity` for `-0`, which we
// then clamp into `z-index` so it serializes as `1` or `-1`.
// Returns an empty string if `sign()` is not supported by this browser.
self.observeZeroSign = function observeZeroSign(expression) {
target.removeAttribute('style');
target.style.setProperty('z-index', `clamp(-1, calc(1 / sign(${expression})), 1)`);

if (target.style.getPropertyValue('z-index') === '') {
return '';
}

return getComputedStyle(target).getPropertyValue('z-index');
};

// Detects whether an expression is `NaN`.
//
// `expr * 0` is `0` for any finite value (including `0` and `-0`), but
// `NaN` for `NaN` and `±infinity`. Adding a finite non-zero value turns
// that into a non-zero value for finite input and keeps `NaN` for
// `NaN`/`infinity`.
//
// `getComputedStyle` collapses `NaN` to `0`, so the computed value is
// `1` for finite and `0` for `NaN`/`infinity`. This differentiates `NaN`
// from a real `0` without inspecting the serialized text.
//
// The term that is added (`1` or `1<unit>`) must have the same type as
// the expression, so the caller passes the unit of the result. Only the
// `+ 1` term is scaled, never the expression itself.
// Returns `1`, `0`, or `''` (unsupported).
self.observeNaN = function observeNaN(property, expression, unit) {
const one = unit ? `1${unit}` : '1';

target.removeAttribute('style');
target.style.setProperty(property, `calc((${expression}) * 0 + ${one})`);

const specified = target.style.getPropertyValue(property);
if (specified === '') {
return '';
}

const computed = getComputedStyle(target).getPropertyValue(property);

return parseFloat(computed) === 1 ? '1' : '0';
};
</script>
</body>
</html>
Loading
Loading