Skip to content

Commit a9048b7

Browse files
committed
css-calc: line-width
1 parent cabcf4b commit a9048b7

File tree

11 files changed

+233
-8
lines changed

11 files changed

+233
-8
lines changed

packages/css-calc/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changes to CSS Calc
22

3+
### Unreleased (minor)
4+
5+
- Add support for `round(line-width, 1.2345px)`
6+
- Add `devicePixelLength` option
7+
38
### 3.1.1
49

510
_February 13, 2026_

packages/css-calc/dist/index.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ export declare type conversionOptions = {
2626
* You can set it to a lower number to suite your needs.
2727
*/
2828
precision?: number;
29+
/**
30+
* The CSS pixel length of one device pixel.
31+
* Used when rounding to `line-width` and similar features
32+
*/
33+
devicePixelLength?: number;
2934
/**
3035
* By default this package will try to preserve units.
3136
* The heuristic to do this is very simplistic.

packages/css-calc/dist/index.mjs

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

packages/css-calc/docs/css-calc.api.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@
386386
},
387387
{
388388
"kind": "Content",
389-
"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}"
389+
"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}"
390390
},
391391
{
392392
"kind": "Content",

packages/css-calc/docs/css-calc.conversionoptions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type conversionOptions = {
1111
onParseError?: (error: ParseError) => void;
1212
globals?: GlobalsWithStrings;
1313
precision?: number;
14+
devicePixelLength?: number;
1415
toCanonicalUnits?: boolean;
1516
censorIntoStandardRepresentableValues?: boolean;
1617
rawPercentages?: boolean;

packages/css-calc/src/functions/calc.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { Calculation } from '../calculation';
22
import type { ComponentValue, SimpleBlockNode } from '@csstools/css-parser-algorithms';
33
import type { Globals } from '../util/globals';
4-
import { TokenType, NumberType, isTokenOpenParen, isTokenDelim, isTokenComma, isTokenIdent, isTokenNumber } from '@csstools/css-tokenizer';
4+
import type { CSSToken, TokenDimension } from '@csstools/css-tokenizer';
5+
import { TokenType, NumberType, isTokenOpenParen, isTokenDelim, isTokenComma, isTokenIdent, isTokenNumber, isTokenDimension } from '@csstools/css-tokenizer';
56
import { addition } from '../operation/addition';
67
import { division } from '../operation/division';
78
import { isCalculation, solve } from '../calculation';
@@ -34,6 +35,8 @@ import { isNone } from '../util/is-none';
3435
import type { conversionOptions } from '../options';
3536
import type { RandomValueSharing} from './random';
3637
import { solveRandom } from './random';
38+
import { snapAsBorderWidth } from '../util/snap-to-border-width';
39+
import { convertUnit } from '../unit-conversions';
3740

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

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

436439
const roundingStrategies = new Set([
437440
'nearest',
441+
'line-width',
438442
'up',
439443
'down',
440444
'to-zero',
@@ -492,7 +496,23 @@ function round(roundNode: FunctionNode, globals: Globals, options: conversionOpt
492496
return -1;
493497
}
494498

499+
if (roundingStrategy === 'line-width') {
500+
const dummyPx: CSSToken = [TokenType.Dimension, '1px', a.value[2], a.value[3], { value: 1, type: NumberType.Integer, unit: 'px' }];
501+
const asPx = convertUnit(dummyPx, a.value);
502+
if (!isTokenDimension(asPx) || asPx[4].unit !== 'px') {
503+
return -1;
504+
}
505+
}
506+
495507
if (!hasComma && bValue.length === 0) {
508+
if (roundingStrategy === 'line-width') {
509+
if ((a.value as TokenDimension)[4].value <= 0) {
510+
return -1;
511+
}
512+
513+
return snapAsBorderWidth(roundNode, a.value, options);
514+
}
515+
496516
bValue.push(
497517
new TokenNode(
498518
[TokenType.Number, '1', a.value[2], a.value[3], { value: 1, type: NumberType.Integer }],

packages/css-calc/src/functions/round.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
1-
import type { Calculation } from '../calculation';
1+
import { solve, type Calculation } from '../calculation';
22
import type { FunctionNode, TokenNode } from '@csstools/css-parser-algorithms';
33
import { convertUnit } from '../unit-conversions';
44
import { resultToCalculation } from './result-to-calculation';
55
import { twoOfSameNumeric } from '../util/kind-of-number';
6-
import { isTokenNumeric, isTokenPercentage } from '@csstools/css-tokenizer';
6+
import { isTokenDimension, isTokenNumeric, isTokenPercentage } from '@csstools/css-tokenizer';
77
import type { conversionOptions } from '../options';
8+
import { snapAsBorderWidth } from '../util/snap-to-border-width';
89

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

16+
if (roundingStrategy === 'line-width' && !isTokenDimension(aToken)) {
17+
return -1;
18+
}
19+
1520
if (!options.rawPercentages && isTokenPercentage(aToken)) {
1621
return -1;
1722
}
@@ -22,14 +27,20 @@ export function solveRound(roundNode: FunctionNode, roundingStrategy: string, a:
2227
}
2328

2429
let result;
30+
// https://drafts.csswg.org/css-values-4/#round-infinities
2531
if (bToken[4].value === 0) {
32+
// In round(A, B), if B is 0, the result is NaN.
2633
result = Number.NaN;
2734
} else if (!Number.isFinite(aToken[4].value) && !Number.isFinite(bToken[4].value)) {
35+
// If A and B are both infinite, the result is NaN.
2836
result = Number.NaN;
2937
} else if (!Number.isFinite(aToken[4].value) && Number.isFinite(bToken[4].value)) {
38+
// If A is infinite but B is finite, the result is the same infinity.
3039
result = aToken[4].value;
3140
} else if (Number.isFinite(aToken[4].value) && !Number.isFinite(bToken[4].value)) {
41+
// If A is finite but B is infinite, the result depends on the <rounding-strategy> and the sign of A:
3242
switch (roundingStrategy) {
43+
// If A is negative (not zero), return −∞. If A is 0⁻, return 0⁻. Otherwise, return 0⁺.
3344
case 'down':
3445
if (aToken[4].value < 0) {
3546
result = -Infinity;
@@ -40,6 +51,7 @@ export function solveRound(roundNode: FunctionNode, roundingStrategy: string, a:
4051
}
4152
break;
4253
case 'up':
54+
// If A is positive (not zero), return +∞. If A is 0⁺, return 0⁺. Otherwise, return 0⁻.
4355
if (aToken[4].value > 0) {
4456
result = +Infinity;
4557
} else if (Object.is(+0, aToken[4].value * 0)) {
@@ -50,16 +62,16 @@ export function solveRound(roundNode: FunctionNode, roundingStrategy: string, a:
5062
break;
5163
case 'to-zero':
5264
case 'nearest':
65+
case 'line-width':
5366
default: {
67+
// If A is positive or 0⁺, return 0⁺. Otherwise, return 0⁻.
5468
if (Object.is(+0, aToken[4].value * 0)) {
5569
result = +0;
5670
} else {
5771
result = -0;
5872
}
5973
}
6074
}
61-
} else if (!Number.isFinite(bToken[4].value)) {
62-
result = aToken[4].value;
6375
} else {
6476
switch (roundingStrategy) {
6577
case 'down':
@@ -72,6 +84,7 @@ export function solveRound(roundNode: FunctionNode, roundingStrategy: string, a:
7284
result = Math.trunc(aToken[4].value / bToken[4].value) * bToken[4].value;
7385
break;
7486
case 'nearest':
87+
case 'line-width':
7588
default: {
7689
let down = Math.floor(aToken[4].value / bToken[4].value) * bToken[4].value;
7790
let up = Math.ceil(aToken[4].value / bToken[4].value) * bToken[4].value;
@@ -84,14 +97,25 @@ export function solveRound(roundNode: FunctionNode, roundingStrategy: string, a:
8497
const downDiff = Math.abs(aToken[4].value - down);
8598
const upDiff = Math.abs(aToken[4].value - up);
8699

87-
if (downDiff === upDiff) {
100+
if (roundingStrategy === 'line-width' && aToken[4].value > 0 && (up === 0 || down === 0)) {
101+
result = up !== 0 ? up : down;
102+
} else if (downDiff === upDiff) {
88103
result = up;
89104
} else if (downDiff < upDiff) {
90105
result = down;
91106
} else {
92107
result = up;
93108
}
94109

110+
if (roundingStrategy === 'line-width') {
111+
const solved = solve(resultToCalculation(roundNode, aToken, result), options);
112+
if (solved === -1) {
113+
return -1;
114+
}
115+
116+
return snapAsBorderWidth(roundNode, solved.value, options);
117+
}
118+
95119
break;
96120
}
97121
}

packages/css-calc/src/options.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ export type conversionOptions = {
2424
*/
2525
precision?: number,
2626

27+
/**
28+
* The CSS pixel length of one device pixel.
29+
* Used when rounding to `line-width` and similar features
30+
*/
31+
devicePixelLength?: number,
32+
2733
/**
2834
* By default this package will try to preserve units.
2935
* The heuristic to do this is very simplistic.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { CSSToken, TokenDimension} from "@csstools/css-tokenizer";
2+
import { isTokenDimension, NumberType, TokenType } from "@csstools/css-tokenizer";
3+
import type { conversionOptions } from "../options";
4+
import { convertUnit } from "../unit-conversions";
5+
import { twoOfSameNumeric } from "./kind-of-number";
6+
import { resultToCalculation } from "../functions/result-to-calculation";
7+
import type { FunctionNode } from "@csstools/css-parser-algorithms";
8+
import type { Calculation } from "../calculation";
9+
10+
export function snapAsBorderWidth(fnNode: FunctionNode, aToken: CSSToken, options: conversionOptions): Calculation | -1 {
11+
if (!isTokenDimension(aToken)) {
12+
return -1;
13+
}
14+
15+
const devicePixelLength = options.devicePixelLength ?? 1;
16+
const devicePixelLengthToken: TokenDimension = [TokenType.Dimension, `${devicePixelLength}px`, aToken[2], aToken[3], { value: devicePixelLength, type: NumberType.Integer, unit: 'px' }];
17+
18+
const aTokenAsPixels = convertUnit(devicePixelLengthToken, aToken);
19+
if (!twoOfSameNumeric(aTokenAsPixels, devicePixelLengthToken)) {
20+
return -1;
21+
}
22+
23+
if (aTokenAsPixels[4].value < 0) {
24+
// Assert: len is non-negative.
25+
return -1;
26+
}
27+
28+
if (Number.isInteger(aTokenAsPixels[4].value / devicePixelLength)) {
29+
// If len is an integer number of device pixels, do nothing.
30+
return resultToCalculation(fnNode, aToken, aToken[4].value);
31+
}
32+
33+
if (aTokenAsPixels[4].value > 0 && aTokenAsPixels[4].value < devicePixelLength) {
34+
// If len is greater than zero, but less than 1 device pixel, round len up to 1 device pixel.
35+
return resultToCalculation(fnNode, aToken, convertUnit(aToken, devicePixelLengthToken)[4].value);
36+
}
37+
38+
if (aTokenAsPixels[4].value > devicePixelLength) {
39+
// If len is greater than 1 device pixel, round it down to the nearest integer number of device pixels.
40+
const down = Math.floor(aTokenAsPixels[4].value / devicePixelLengthToken[4].value) * devicePixelLengthToken[4].value;
41+
aTokenAsPixels[4].value = down;
42+
return resultToCalculation(fnNode, aToken, convertUnit(aToken, aTokenAsPixels)[4].value);
43+
}
44+
45+
return resultToCalculation(fnNode, aToken, aToken[4].value);
46+
}

packages/css-calc/test/additional/index.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ import './mod-rem-infinity.mjs';
22
import './random.mjs';
33
import './sign-abs-equivalent.mjs';
44
import './tan-asymptotes.mjs';
5+
import './round-line-width.mjs';

0 commit comments

Comments
 (0)