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
5 changes: 5 additions & 0 deletions packages/css-calc/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changes to CSS Calc

### Unreleased (minor)

- Add support for `round(line-width, 1.2345px)`
- Add `devicePixelLength` option

### 3.1.1

_February 13, 2026_
Expand Down
5 changes: 5 additions & 0 deletions packages/css-calc/dist/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export declare type conversionOptions = {
* You can set it to a lower number to suite your needs.
*/
precision?: number;
/**
* The CSS pixel length of one device pixel.
* Used when rounding to `line-width` and similar features
*/
devicePixelLength?: number;
/**
* By default this package will try to preserve units.
* The heuristic to do this is very simplistic.
Expand Down
2 changes: 1 addition & 1 deletion packages/css-calc/dist/index.mjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/css-calc/docs/css-calc.api.json
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@
},
{
"kind": "Content",
"text": ";\n precision?: number;\n toCanonicalUnits?: boolean;\n censorIntoStandardRepresentableValues?: boolean;\n rawPercentages?: boolean;\n randomCaching?: {\n propertyName: string;\n propertyN: number;\n elementID: string;\n documentID: string;\n };\n}"
"text": ";\n precision?: number;\n devicePixelLength?: number;\n toCanonicalUnits?: boolean;\n censorIntoStandardRepresentableValues?: boolean;\n rawPercentages?: boolean;\n randomCaching?: {\n propertyName: string;\n propertyN: number;\n elementID: string;\n documentID: string;\n };\n}"
},
{
"kind": "Content",
Expand Down
1 change: 1 addition & 0 deletions packages/css-calc/docs/css-calc.conversionoptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type conversionOptions = {
onParseError?: (error: ParseError) => void;
globals?: GlobalsWithStrings;
precision?: number;
devicePixelLength?: number;
toCanonicalUnits?: boolean;
censorIntoStandardRepresentableValues?: boolean;
rawPercentages?: boolean;
Expand Down
22 changes: 21 additions & 1 deletion packages/css-calc/src/functions/calc.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Calculation } from '../calculation';
import type { ComponentValue, SimpleBlockNode } from '@csstools/css-parser-algorithms';
import type { Globals } from '../util/globals';
import { TokenType, NumberType, isTokenOpenParen, isTokenDelim, isTokenComma, isTokenIdent, isTokenNumber } from '@csstools/css-tokenizer';
import type { CSSToken, TokenDimension } from '@csstools/css-tokenizer';
import { TokenType, NumberType, isTokenOpenParen, isTokenDelim, isTokenComma, isTokenIdent, isTokenNumber, isTokenDimension } from '@csstools/css-tokenizer';
import { addition } from '../operation/addition';
import { division } from '../operation/division';
import { isCalculation, solve } from '../calculation';
Expand Down Expand Up @@ -34,6 +35,8 @@ import { isNone } from '../util/is-none';
import type { conversionOptions } from '../options';
import type { RandomValueSharing} from './random';
import { solveRandom } from './random';
import { snapAsBorderWidth } from '../util/snap-to-border-width';
import { convertUnit } from '../unit-conversions';

type mathFunction = (node: FunctionNode, globals: Globals, options: conversionOptions) => Calculation | -1;

Expand Down Expand Up @@ -435,6 +438,7 @@ function min(minNode: FunctionNode, globals: Globals, options: conversionOptions

const roundingStrategies = new Set([
'nearest',
'line-width',
'up',
'down',
'to-zero',
Expand Down Expand Up @@ -492,7 +496,23 @@ function round(roundNode: FunctionNode, globals: Globals, options: conversionOpt
return -1;
}

if (roundingStrategy === 'line-width') {
const dummyPx: CSSToken = [TokenType.Dimension, '1px', a.value[2], a.value[3], { value: 1, type: NumberType.Integer, unit: 'px' }];
const asPx = convertUnit(dummyPx, a.value);
if (!isTokenDimension(asPx) || asPx[4].unit !== 'px') {
return -1;
}
}

if (!hasComma && bValue.length === 0) {
if (roundingStrategy === 'line-width') {
if ((a.value as TokenDimension)[4].value <= 0) {
return -1;
}

return snapAsBorderWidth(roundNode, a.value, options);
}

bValue.push(
new TokenNode(
[TokenType.Number, '1', a.value[2], a.value[3], { value: 1, type: NumberType.Integer }],
Expand Down
34 changes: 29 additions & 5 deletions packages/css-calc/src/functions/round.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import type { Calculation } from '../calculation';
import { solve, type Calculation } from '../calculation';
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 { isTokenNumeric, isTokenPercentage } from '@csstools/css-tokenizer';
import { isTokenDimension, isTokenNumeric, isTokenPercentage } from '@csstools/css-tokenizer';
import type { conversionOptions } from '../options';
import { snapAsBorderWidth } from '../util/snap-to-border-width';

export function solveRound(roundNode: FunctionNode, roundingStrategy: string, a: TokenNode, b: TokenNode, options: conversionOptions): Calculation | -1 {
const aToken = a.value;
if (!isTokenNumeric(aToken)) {
return -1;
}

if (roundingStrategy === 'line-width' && !isTokenDimension(aToken)) {
return -1;
}

if (!options.rawPercentages && isTokenPercentage(aToken)) {
return -1;
}
Expand All @@ -22,14 +27,20 @@ export function solveRound(roundNode: FunctionNode, roundingStrategy: string, a:
}

let result;
// https://drafts.csswg.org/css-values-4/#round-infinities
if (bToken[4].value === 0) {
// In round(A, B), if B is 0, the result is NaN.
result = Number.NaN;
} else if (!Number.isFinite(aToken[4].value) && !Number.isFinite(bToken[4].value)) {
// If A and B are both infinite, the result is NaN.
result = Number.NaN;
} else if (!Number.isFinite(aToken[4].value) && Number.isFinite(bToken[4].value)) {
// If A is infinite but B is finite, the result is the same infinity.
result = aToken[4].value;
} else if (Number.isFinite(aToken[4].value) && !Number.isFinite(bToken[4].value)) {
// If A is finite but B is infinite, the result depends on the <rounding-strategy> and the sign of A:
switch (roundingStrategy) {
// If A is negative (not zero), return −∞. If A is 0⁻, return 0⁻. Otherwise, return 0⁺.
case 'down':
if (aToken[4].value < 0) {
result = -Infinity;
Expand All @@ -40,6 +51,7 @@ export function solveRound(roundNode: FunctionNode, roundingStrategy: string, a:
}
break;
case 'up':
// If A is positive (not zero), return +∞. If A is 0⁺, return 0⁺. Otherwise, return 0⁻.
if (aToken[4].value > 0) {
result = +Infinity;
} else if (Object.is(+0, aToken[4].value * 0)) {
Expand All @@ -50,16 +62,16 @@ export function solveRound(roundNode: FunctionNode, roundingStrategy: string, a:
break;
case 'to-zero':
case 'nearest':
case 'line-width':
default: {
// If A is positive or 0⁺, return 0⁺. Otherwise, return 0⁻.
if (Object.is(+0, aToken[4].value * 0)) {
result = +0;
} else {
result = -0;
}
}
}
} else if (!Number.isFinite(bToken[4].value)) {
result = aToken[4].value;
} else {
switch (roundingStrategy) {
case 'down':
Expand All @@ -72,6 +84,7 @@ export function solveRound(roundNode: FunctionNode, roundingStrategy: string, a:
result = Math.trunc(aToken[4].value / bToken[4].value) * bToken[4].value;
break;
case 'nearest':
case 'line-width':
default: {
let down = Math.floor(aToken[4].value / bToken[4].value) * bToken[4].value;
let up = Math.ceil(aToken[4].value / bToken[4].value) * bToken[4].value;
Expand All @@ -84,14 +97,25 @@ export function solveRound(roundNode: FunctionNode, roundingStrategy: string, a:
const downDiff = Math.abs(aToken[4].value - down);
const upDiff = Math.abs(aToken[4].value - up);

if (downDiff === upDiff) {
if (roundingStrategy === 'line-width' && aToken[4].value > 0 && (up === 0 || down === 0)) {
result = up !== 0 ? up : down;
} else if (downDiff === upDiff) {
result = up;
} else if (downDiff < upDiff) {
result = down;
} else {
result = up;
}

if (roundingStrategy === 'line-width') {
const solved = solve(resultToCalculation(roundNode, aToken, result), options);
if (solved === -1) {
return -1;
}

return snapAsBorderWidth(roundNode, solved.value, options);
}

break;
}
}
Expand Down
6 changes: 6 additions & 0 deletions packages/css-calc/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export type conversionOptions = {
*/
precision?: number,

/**
* The CSS pixel length of one device pixel.
* Used when rounding to `line-width` and similar features
*/
devicePixelLength?: number,

/**
* By default this package will try to preserve units.
* The heuristic to do this is very simplistic.
Expand Down
46 changes: 46 additions & 0 deletions packages/css-calc/src/util/snap-to-border-width.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { CSSToken, TokenDimension} from "@csstools/css-tokenizer";
import { isTokenDimension, NumberType, TokenType } from "@csstools/css-tokenizer";
import type { conversionOptions } from "../options";
import { convertUnit } from "../unit-conversions";
import { twoOfSameNumeric } from "./kind-of-number";
import { resultToCalculation } from "../functions/result-to-calculation";
import type { FunctionNode } from "@csstools/css-parser-algorithms";
import type { Calculation } from "../calculation";

export function snapAsBorderWidth(fnNode: FunctionNode, aToken: CSSToken, options: conversionOptions): Calculation | -1 {
if (!isTokenDimension(aToken)) {
return -1;
}

const devicePixelLength = options.devicePixelLength ?? 1;
const devicePixelLengthToken: TokenDimension = [TokenType.Dimension, `${devicePixelLength}px`, aToken[2], aToken[3], { value: devicePixelLength, type: NumberType.Integer, unit: 'px' }];

const aTokenAsPixels = convertUnit(devicePixelLengthToken, aToken);
if (!twoOfSameNumeric(aTokenAsPixels, devicePixelLengthToken)) {
return -1;
}

if (aTokenAsPixels[4].value < 0) {
// Assert: len is non-negative.
return -1;
}

if (Number.isInteger(aTokenAsPixels[4].value / devicePixelLength)) {
// If len is an integer number of device pixels, do nothing.
return resultToCalculation(fnNode, aToken, aToken[4].value);
}

if (aTokenAsPixels[4].value > 0 && aTokenAsPixels[4].value < devicePixelLength) {
// If len is greater than zero, but less than 1 device pixel, round len up to 1 device pixel.
return resultToCalculation(fnNode, aToken, convertUnit(aToken, devicePixelLengthToken)[4].value);
}

if (aTokenAsPixels[4].value > devicePixelLength) {
// If len is greater than 1 device pixel, round it down to the nearest integer number of device pixels.
const down = Math.floor(aTokenAsPixels[4].value / devicePixelLengthToken[4].value) * devicePixelLengthToken[4].value;
aTokenAsPixels[4].value = down;
return resultToCalculation(fnNode, aToken, convertUnit(aToken, aTokenAsPixels)[4].value);
}

return resultToCalculation(fnNode, aToken, aToken[4].value);
}
1 change: 1 addition & 0 deletions packages/css-calc/test/additional/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ import './mod-rem-infinity.mjs';
import './random.mjs';
import './sign-abs-equivalent.mjs';
import './tan-asymptotes.mjs';
import './round-line-width.mjs';
117 changes: 117 additions & 0 deletions packages/css-calc/test/additional/round-line-width.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { calc } from '@csstools/css-calc';
import assert from 'node:assert';

assert.strictEqual(
calc('round(line-width, 0.001px)'),
'1px',
);

assert.strictEqual(
calc('round(line-width, 0px)'),
'round(line-width, 0px)',
);

assert.strictEqual(
calc('round(line-width, -1px)'),
'round(line-width, -1px)',
);

assert.strictEqual(
calc('round(line-width, 0.001px)', { devicePixelLength: 0.5 }),
'0.5px',
);

assert.strictEqual(
calc('round(line-width, 23.45343px)', { devicePixelLength: 0.5 }),
'23px',
);

assert.strictEqual(
calc('round(line-width, 23.45343px, 11px)', { devicePixelLength: 0.5 }),
'22px',
);

assert.strictEqual(
calc('round(line-width, 5px, 1.3px)', { devicePixelLength: 0.5 }),
'5px',
);

assert.strictEqual(
calc('round(line-width, 5px, 1.6px)', { devicePixelLength: 0.5 }),
'4.5px',
);

assert.strictEqual(
calc('round(line-width, 5px, 1.7px)', { devicePixelLength: 0.5 }),
'5px',
);

assert.strictEqual(
calc('round(line-width, 5px, 1.8px)', { devicePixelLength: 0.5 }),
'5px',
);

assert.strictEqual(
calc('round(line-width, 5px, 1.9px)', { devicePixelLength: 0.5 }),
'5.5px',
);

assert.strictEqual(
calc('round(line-width, 5px, 2px)', { devicePixelLength: 0.5 }),
'6px',
);

assert.strictEqual(
calc('round(line-width, 5pt, 2pt)', { devicePixelLength: 0.5 }),
'6pt',
);

assert.strictEqual(
calc('round(line-width, 5mm, 2mm)', { devicePixelLength: 0.5 }),
'5.953125mm',
);

assert.strictEqual(
calc('round(line-width, -5px, 2px)', { devicePixelLength: 0.5 }),
'round(line-width, -5px, 2px)',
);

assert.strictEqual(
calc('round(line-width, 0.25px)', { devicePixelLength: 0.5 }),
'0.5px',
);

assert.strictEqual(
calc('round(line-width, 0.99px)', { devicePixelLength: 0.5 }),
'0.5px',
);

assert.strictEqual(
calc('round(line-width, 0.99px, 0.01px)', { devicePixelLength: 0.5 }),
'0.5px',
);

assert.strictEqual(
calc('round(line-width, 1.01px)', { devicePixelLength: 0.5 }),
'1px',
);

assert.strictEqual(
calc('round(line-width, 1.01px, 0.01px)', { devicePixelLength: 0.5 }),
'1px',
);

assert.strictEqual(
calc('round(line-width, 11)', { devicePixelLength: 0.5 }),
'round(line-width, 11)',
);

assert.strictEqual(
calc('round(line-width, 11, 1)', { devicePixelLength: 0.5 }),
'round(line-width, 11, 1)',
);

assert.strictEqual(
calc('round(line-width, 11px, 1)', { devicePixelLength: 0.5 }),
'round(line-width, 11px, 1)',
);
Loading