@thejaredwilcurt/csslop 0.0.39 → 0.0.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/value/math.js CHANGED
@@ -2,13 +2,17 @@
2
2
  * @file Simplifies CSS math functions (calc, min, max) by folding constant expressions, flattening nested calcs, and converting absolute lengths to pixels.
3
3
  */
4
4
 
5
- import { calc } from '@csstools/css-calc';
6
-
7
5
  import { findMatchingParenthesis } from '../parser/source-search.js';
8
6
 
7
+ import {
8
+ formatUnitTotals,
9
+ MATH_FUNCTION_NAMES,
10
+ simplifyCalc
11
+ } from './math-expression.js';
9
12
  import {
10
13
  convertAbsoluteLengthToPx,
11
- formatDimension,
14
+ formatResolvedDimension,
15
+ formatResolvedNumber,
12
16
  roundCompactNumber
13
17
  } from './shared.js';
14
18
 
@@ -85,25 +89,6 @@ function keepSeparatorAfterDissolvedFunction (replacer) {
85
89
  };
86
90
  }
87
91
 
88
- /**
89
- * The functions whose arguments are a math expression, where `*` and `/` are
90
- * arithmetic operators rather than characters of some other syntax.
91
- *
92
- * @type {Set<string>}
93
- */
94
- const MATH_FUNCTION_NAMES = new Set([
95
- 'abs',
96
- 'calc',
97
- 'clamp',
98
- 'hypot',
99
- 'max',
100
- 'min',
101
- 'mod',
102
- 'rem',
103
- 'round',
104
- 'sign'
105
- ]);
106
-
107
92
  /**
108
93
  * Matches a single character that may appear in a CSS function name.
109
94
  *
@@ -246,19 +231,188 @@ function convertReciprocalMultiplicationToDivision (expression) {
246
231
  }
247
232
 
248
233
  /**
249
- * The units a folded calc() expression leads with, in the order they are
250
- * written. Every other unit follows them alphabetically.
234
+ * Matches a whole expression that divides a percentage by a plain number,
235
+ * capturing the percentage and the divisor it is shared out between.
236
+ *
237
+ * @type {RegExp}
238
+ */
239
+ const PERCENTAGE_DIVISION = /^([+-]?(?:\d+|\d*\.\d+))%\s*\/\s*([+-]?(?:\d+|\d*\.\d+))$/;
240
+
241
+ /**
242
+ * Picks between what a calculation resolved to and the calculation itself,
243
+ * keeping whichever takes fewer characters to write. A tie goes to the
244
+ * resolved value, which states the same thing without the arithmetic.
245
+ *
246
+ * @param {string} resolved The value the calculation resolves to.
247
+ * @param {string} calculation The calculation, written as compactly as it can be.
248
+ * @return {string} The shorter of the two.
249
+ */
250
+ function shorterOfResolutionAndCalculation (resolved, calculation) {
251
+ if (resolved.length <= calculation.length) {
252
+ return resolved;
253
+ }
254
+ return calculation;
255
+ }
256
+
257
+ /**
258
+ * Writes a calculation as compactly as its syntax allows, squeezing out every
259
+ * space but the ones a `+` or a `-` needs to be read as an operator.
260
+ *
261
+ * @param {string} inner The contents of the `calc()`.
262
+ * @return {string} The whole `calc()`, written as compactly as it can be.
263
+ */
264
+ function compactCalculation (inner) {
265
+ // Remove whitespace around multiplication and division operators
266
+ const compactInner = inner.replace(/\s+/g, ' ').trim().replace(/\s*([*/])\s*/g, '$1');
267
+ return 'calc(' + compactInner + ')';
268
+ }
269
+
270
+ /**
271
+ * Picks between a calculation and what resolving its arithmetic came to. A
272
+ * resolved value states the same thing without any arithmetic, so it wins a
273
+ * tie, but a result that is itself a calculation only states the same terms
274
+ * another way, and writing them out again for no saving gains nothing.
275
+ *
276
+ * @param {string} resolved The value the calculation resolved to.
277
+ * @param {string} calculation The calculation, written as compactly as it can be.
278
+ * @return {string} Whichever of the two the value is written as.
279
+ */
280
+ function chooseResolvedCalculation (resolved, calculation) {
281
+ // A resolution that kept its function form left terms behind that only
282
+ // resolve once the element they are measured against is laid out
283
+ if (resolved.startsWith('calc(') && resolved.length >= calculation.length) {
284
+ return calculation;
285
+ }
286
+ return shorterOfResolutionAndCalculation(resolved, calculation);
287
+ }
288
+
289
+ /**
290
+ * Resolves a percentage divided by a number into the single percentage it
291
+ * equals, which is how `calc(100%/16)` comes to be written as `6.25%`. The
292
+ * division stays as it was written when the result is no shorter, and when
293
+ * the divisor is zero, since that resolves to nothing a stylesheet can state.
251
294
  *
252
- * @type {Array}
295
+ * @param {string} expression The contents of a `calc()`, with its whitespace collapsed.
296
+ * @return {string|null} The shorter of the resolved percentage and the division, or null when the expression is not one.
253
297
  */
254
- const PREFERRED_UNIT_ORDER = ['%', '', 'px'];
298
+ function resolvePercentageDivision (expression) {
299
+ const division = expression.match(PERCENTAGE_DIVISION);
300
+ if (!division) {
301
+ return null;
302
+ }
303
+ const [, percentage, divisor] = division;
304
+ const calculation = 'calc(' + percentage + '%/' + divisor + ')';
305
+ if (Number(divisor) === 0) {
306
+ return calculation;
307
+ }
308
+ const resolved = formatResolvedNumber(Number(percentage) / Number(divisor)) + '%';
309
+ return shorterOfResolutionAndCalculation(resolved, calculation);
310
+ }
255
311
 
256
312
  /**
257
- * The same leading units, for excluding them from the alphabetical remainder.
313
+ * The properties whose values every CSS specification defines as
314
+ * non-negative, so that a math function declared on one of them has its
315
+ * result clamped rather than read as written.
258
316
  *
259
317
  * @type {Set<string>}
260
318
  */
261
- const PREFERRED_UNITS = new Set(PREFERRED_UNIT_ORDER);
319
+ const NON_NEGATIVE_PROPERTIES = new Set([
320
+ 'animation-duration',
321
+ 'animation-iteration-count',
322
+ 'background-size',
323
+ 'block-size',
324
+ 'border-block-end-width',
325
+ 'border-block-start-width',
326
+ 'border-block-width',
327
+ 'border-bottom-left-radius',
328
+ 'border-bottom-right-radius',
329
+ 'border-bottom-width',
330
+ 'border-end-end-radius',
331
+ 'border-end-start-radius',
332
+ 'border-image-outset',
333
+ 'border-image-width',
334
+ 'border-inline-end-width',
335
+ 'border-inline-start-width',
336
+ 'border-inline-width',
337
+ 'border-left-width',
338
+ 'border-radius',
339
+ 'border-right-width',
340
+ 'border-start-end-radius',
341
+ 'border-start-start-radius',
342
+ 'border-top-left-radius',
343
+ 'border-top-right-radius',
344
+ 'border-top-width',
345
+ 'border-width',
346
+ 'column-count',
347
+ 'column-gap',
348
+ 'column-rule-width',
349
+ 'column-width',
350
+ 'flex-basis',
351
+ 'flex-grow',
352
+ 'flex-shrink',
353
+ 'font-size',
354
+ 'gap',
355
+ 'height',
356
+ 'inline-size',
357
+ 'line-height',
358
+ 'mask-size',
359
+ 'max-block-size',
360
+ 'max-height',
361
+ 'max-inline-size',
362
+ 'max-width',
363
+ 'min-block-size',
364
+ 'min-height',
365
+ 'min-inline-size',
366
+ 'min-width',
367
+ 'outline-width',
368
+ 'padding',
369
+ 'padding-block',
370
+ 'padding-block-end',
371
+ 'padding-block-start',
372
+ 'padding-bottom',
373
+ 'padding-inline',
374
+ 'padding-inline-end',
375
+ 'padding-inline-start',
376
+ 'padding-left',
377
+ 'padding-right',
378
+ 'padding-top',
379
+ 'perspective',
380
+ 'row-gap',
381
+ 'shape-margin',
382
+ 'stroke-width',
383
+ 'tab-size',
384
+ 'transition-duration',
385
+ 'width'
386
+ ]);
387
+
388
+ /**
389
+ * Matches a value written as a single negative number, with or without the
390
+ * unit or percent sign that follows its digits.
391
+ *
392
+ * @type {RegExp}
393
+ */
394
+ const NEGATIVE_NUMBER_VALUE = /^-(?:\d+|\d*\.\d+)(?:[a-z]+|%)?$/i;
395
+
396
+ /**
397
+ * Clamps what a math function resolved to into the range its property
398
+ * accepts. A negative length written out directly makes the declaration
399
+ * invalid and the CSS engine throws it away, but a math function is allowed
400
+ * to resolve to one: its result is clamped to the nearest value the property
401
+ * does take, which on a property that only accepts non-negative values is
402
+ * zero, as the range checking section of the CSS Values specification
403
+ * describes: https://drafts.csswg.org/css-values/#calc-range.
404
+ *
405
+ * @param {string} resolvedValue The value the math function resolved to.
406
+ * @param {string} property The property the math function is declared on.
407
+ * @return {string} The resolved value, held inside the property's range.
408
+ */
409
+ function clampResolvedValueToPropertyRange (resolvedValue, property) {
410
+ const acceptsNegativeValues = !NON_NEGATIVE_PROPERTIES.has(String(property).toLowerCase());
411
+ if (acceptsNegativeValues || !NEGATIVE_NUMBER_VALUE.test(resolvedValue)) {
412
+ return resolvedValue;
413
+ }
414
+ return '0';
415
+ }
262
416
 
263
417
  /**
264
418
  * Attempts to simplify a calc() expression by combining like-unit terms and evaluating pure arithmetic, returning the simplified string or null if folding is not possible.
@@ -276,15 +430,15 @@ function tryFoldCalcExpression (expression) {
276
430
  expr = expr.replace(/\(([^()]+)\)/g, '$1');
277
431
  // Fold: <number> * <number><unit> → computed result in same unit
278
432
  expr = expr.replace(/(-?(?:\d*\.\d+|\d+))\s*\*\s*(-?(?:\d*\.\d+|\d+))(px|pt|pc|in|cm|mm|q|%)/gi, (match, a, b, unit) => {
279
- return formatDimension(parseFloat(a) * parseFloat(b), unit);
433
+ return formatResolvedDimension(parseFloat(a) * parseFloat(b), unit);
280
434
  });
281
435
  // Fold: <number><unit> * <number> → computed result in same unit
282
436
  expr = expr.replace(/(-?(?:\d*\.\d+|\d+))(px|pt|pc|in|cm|mm|q|%)\s*\*\s*(-?(?:\d*\.\d+|\d+))/gi, (match, a, unit, b) => {
283
- return formatDimension(parseFloat(a) * parseFloat(b), unit);
437
+ return formatResolvedDimension(parseFloat(a) * parseFloat(b), unit);
284
438
  });
285
439
  // Fold: <number><unit> / <number> → computed result in same unit
286
440
  expr = expr.replace(/(-?(?:\d*\.\d+|\d+))(px|pt|pc|in|cm|mm|q)\s*\/\s*(-?(?:\d*\.\d+|\d+))/gi, (match, value, unit, divisor) => {
287
- return formatDimension(parseFloat(value) / parseFloat(divisor), unit);
441
+ return formatResolvedDimension(parseFloat(value) / parseFloat(divisor), unit);
288
442
  });
289
443
  // Collapse zero-with-unit terms (e.g. 0px, 0%) to plain 0 or remove them
290
444
  expr = expr.replace(/(^|[+-])\s*0(?:px|pt|pc|in|cm|mm|q|%)\b/g, (match, sign) => {
@@ -339,52 +493,109 @@ function tryFoldCalcExpression (expression) {
339
493
  totals.set(unit, (totals.get(unit) || 0) + number);
340
494
  }
341
495
 
342
- const orderedUnits = [...PREFERRED_UNIT_ORDER, ...[...totals.keys()].filter((unit) => {
343
- return !PREFERRED_UNITS.has(unit);
344
- }).sort()];
345
- const outputTerms = [];
496
+ return formatUnitTotals(totals);
497
+ }
346
498
 
347
- for (const unit of orderedUnits) {
348
- if (!totals.has(unit)) {
349
- continue;
350
- }
351
- const value = totals.get(unit);
352
- if (Math.abs(value) < 1e-12) {
353
- continue;
354
- }
355
- outputTerms.push({ unit, value });
499
+ /**
500
+ * Matches a whole expression that scales a percentage by a plain number,
501
+ * written either way around. Both operands are already as short as they can
502
+ * be, so the expression is only ever squeezed, never resolved.
503
+ *
504
+ * @type {RegExp}
505
+ */
506
+ const PERCENTAGE_MULTIPLICATION = /^(?:-?(?:\d*\.\d+|\d+)%\s*\*\s*-?(?:\d*\.\d+|\d+)|-?(?:\d*\.\d+|\d+)\s*\*\s*-?(?:\d*\.\d+|\d+)%)$/;
507
+
508
+ /**
509
+ * Matches a value that is a single percentage, which is all a calculation
510
+ * leaves behind once every term of it has resolved into one.
511
+ *
512
+ * @type {RegExp}
513
+ */
514
+ const RESOLVED_PERCENTAGE = /^-?(?:\d+|\d*\.\d+)%$/;
515
+
516
+ /**
517
+ * Chooses how a calculation that shares a percentage out resolves. The
518
+ * library states the result to the full precision a float carries, which is
519
+ * both longer than the calculation it replaces and finer than a stylesheet
520
+ * has any use for, so the result is rounded into the budget a resolved number
521
+ * is written within and kept only when it is the shorter of the two.
522
+ *
523
+ * @param {string} simplified The result the calculation library returned.
524
+ * @param {string} compactInner The contents of the `calc()`, with its whitespace collapsed.
525
+ * @param {string} match The whole `calc()` as it was written.
526
+ * @return {string} How the resolved calculation is written.
527
+ */
528
+ function chooseResolvedPercentage (simplified, compactInner, match) {
529
+ // A division sign written after a percentage, which is what the resolved percentage was shared out by
530
+ const dividesAPercentage = RESOLVED_PERCENTAGE.test(simplified) && /%\s*\//.test(match);
531
+ if (!dividesAPercentage) {
532
+ return simplified;
356
533
  }
534
+ // Remove whitespace around division operator
535
+ const calculation = 'calc(' + compactInner.replace(/\s*\/\s*/g, '/') + ')';
536
+ const resolved = formatResolvedNumber(parseFloat(simplified)) + '%';
537
+ return shorterOfResolutionAndCalculation(resolved, calculation);
538
+ }
357
539
 
358
- if (!outputTerms.length) {
359
- return '0';
540
+ /**
541
+ * Resolves the contents of a `calc()` into the shortest way of writing the
542
+ * same value, which is either the value the arithmetic works out to or the
543
+ * calculation itself with the whitespace squeezed out of it.
544
+ *
545
+ * @param {string} match The whole `calc()` as it was written.
546
+ * @param {string} compactInner The contents of the `calc()`, with its whitespace collapsed.
547
+ * @return {string} The shortest way of writing what the calculation states.
548
+ */
549
+ function resolveCalcExpression (match, compactInner) {
550
+ if (PERCENTAGE_MULTIPLICATION.test(compactInner)) {
551
+ return compactCalculation(compactInner);
360
552
  }
361
553
 
362
- if (outputTerms.length === 1) {
363
- const { unit, value } = outputTerms[0];
364
- if (unit) {
365
- return roundCompactNumber(value) + unit;
366
- }
367
- return roundCompactNumber(value);
554
+ const dividedPercentage = resolvePercentageDivision(compactInner);
555
+ if (dividedPercentage) {
556
+ return dividedPercentage;
557
+ }
558
+
559
+ const folded = tryFoldCalcExpression(compactInner);
560
+ if (folded) {
561
+ return folded;
368
562
  }
369
563
 
370
- const [first, ...rest] = outputTerms;
371
- let result = roundCompactNumber(first.value) + first.unit;
372
- for (const term of rest) {
373
- const sign = term.value < 0 ? '-' : '+';
374
- result += ' ' + sign + ' ' + roundCompactNumber(Math.abs(term.value)) + term.unit;
564
+ const simplified = simplifyCalc(match);
565
+ if (simplified === null) {
566
+ return match;
375
567
  }
376
- return 'calc(' + result + ')';
568
+ const resolved = chooseResolvedPercentage(simplified, compactInner, match);
569
+ return chooseResolvedCalculation(resolved, compactCalculation(compactInner));
377
570
  }
378
571
 
379
572
  /**
380
- * Simplifies calc(), min(), and max() expressions within a CSS value string using the `@csstools`/css-calc library, falling back to the original value on failure.
573
+ * Matches a math function other than `calc()` whose arguments hold no further
574
+ * parentheses, so that the whole of the function is matched. A `calc()` is
575
+ * left to its own pass, which weighs what the calculation resolves to against
576
+ * the calculation itself.
381
577
  *
382
- * @param {string} value The CSS value string containing math functions to simplify.
383
- * @param {string} property The CSS property name, used for context-aware simplification.
384
- * @param {string} originalValue The original unmodified value to fall back to if simplification produces an invalid result.
385
- * @return {string} The value with math functions simplified where possible.
578
+ * @type {RegExp}
386
579
  */
387
- function normalizeMathFunctions (value, property, originalValue = '') {
580
+ const RESOLVABLE_MATH_FUNCTION = new RegExp(
581
+ '\\b(?:' +
582
+ [...MATH_FUNCTION_NAMES].filter((name) => {
583
+ return name !== 'calc';
584
+ }).join('|') +
585
+ ')\\([^()]+\\)',
586
+ 'gi'
587
+ );
588
+
589
+ /**
590
+ * Runs one pass of the math simplifications over a value: the rewrites that
591
+ * state a calculation more compactly, and the resolutions that replace a math
592
+ * function with the value it works out to.
593
+ *
594
+ * @param {string} value The CSS value string containing math functions to simplify.
595
+ * @param {string} property The CSS property name, whose range every resolved result is clamped into.
596
+ * @return {string} The value with its math functions simplified where possible.
597
+ */
598
+ function simplifyMathFunctionsOnce (value, property) {
388
599
  let result = rewriteMathFunctionContents(convertReciprocalMultiplicationToDivision, value);
389
600
 
390
601
  // Unwrap calc(1 / (1 / x)) → x (double-reciprocal identity)
@@ -400,50 +611,47 @@ function normalizeMathFunctions (value, property, originalValue = '') {
400
611
  return 'calc(' + inner + ')';
401
612
  }));
402
613
 
403
- // Simplify min()/max() expressions using @csstools/css-calc
404
- result = result.replace(/\b(min|max)\(([^()]+)\)/gi, keepSeparatorAfterDissolvedFunction((match) => {
405
- try {
406
- const simplified = calc(match);
407
- return typeof simplified === 'string' ? simplified : match;
408
- } catch {
614
+ // Resolve every math function that compares or rounds its arguments
615
+ result = result.replace(RESOLVABLE_MATH_FUNCTION, keepSeparatorAfterDissolvedFunction((match) => {
616
+ const simplified = simplifyCalc(match);
617
+ if (simplified === null) {
409
618
  return match;
410
619
  }
620
+ return clampResolvedValueToPropertyRange(simplified, property);
411
621
  }));
412
622
 
413
- // Simplify calc() expressions using constant folding and @csstools/css-calc
623
+ // Simplify calc() expressions using constant folding and expression resolution
414
624
  result = result.replace(/calc\(([^()]+)\)/gi, keepSeparatorAfterDissolvedFunction((match, inner) => {
415
625
  // Collapse whitespace inside calc expression
416
626
  const compactInner = inner.replace(/\s+/g, ' ').trim();
417
- // Preserve percent-times-number expressions (e.g. 50%*2 or 2*50%) — just strip inner spaces
418
- if (/^(?:-?(?:\d*\.\d+|\d+)%\s*\*\s*-?(?:\d*\.\d+|\d+)|-?(?:\d*\.\d+|\d+)\s*\*\s*-?(?:\d*\.\d+|\d+)%)$/i.test(compactInner)) {
419
- // Remove whitespace around multiplication/division operators
420
- return 'calc(' + compactInner.replace(/\s*([*/])\s*/g, '$1') + ')';
421
- }
422
- // Preserve percent/number division expressions (e.g. 100%/3) — just strip inner spaces
423
- if (/^\d+(?:\.\d+)?%\s*\/\s*\d+(?:\.\d+)?$/i.test(compactInner)) {
424
- // Remove whitespace around division operator
425
- return 'calc(' + compactInner.replace(/\s*\/\s*/g, '/') + ')';
426
- }
627
+ const resolved = resolveCalcExpression(match, compactInner);
628
+ return clampResolvedValueToPropertyRange(resolved, property);
629
+ }));
427
630
 
428
- const folded = tryFoldCalcExpression(compactInner);
429
- if (folded) {
430
- return folded;
431
- }
631
+ return result;
632
+ }
432
633
 
433
- try {
434
- const simplified = calc(match);
435
- if (typeof simplified !== 'string') {
436
- return match;
437
- }
438
- // If simplified to a bare percentage but original had division, preserve the calc form
439
- if (/^-?(?:\d+|\d*\.\d+)%$/.test(simplified) && /%\s*\//.test(match)) {
440
- return 'calc(' + compactInner.replace(/\s*\/\s*/g, '/') + ')';
441
- }
442
- return simplified;
443
- } catch {
444
- return match;
445
- }
446
- }));
634
+ /**
635
+ * Simplifies every math function within a CSS value string, resolving the ones
636
+ * whose operands are all known and stating the rest as compactly as they can
637
+ * be written.
638
+ *
639
+ * @param {string} value The CSS value string containing math functions to simplify.
640
+ * @param {string} property The CSS property name, whose range every resolved result is clamped into.
641
+ * @param {string} originalValue The value as it was authored, which tells whether a result was ever part of a calculation.
642
+ * @return {string} The value with math functions simplified where possible.
643
+ */
644
+ function normalizeMathFunctions (value, property, originalValue = '') {
645
+ let result = value;
646
+ let previous;
647
+
648
+ do {
649
+ previous = result;
650
+ // Resolving a math function leaves a plain value where it stood, which can
651
+ // bring the function it was nested in within reach of the next pass, so
652
+ // the value is simplified until it settles
653
+ result = simplifyMathFunctionsOnce(result, property);
654
+ } while (result !== previous);
447
655
 
448
656
  // When calc() folded to an absolute-length unit (pt, pc, in, cm, mm, q), convert to pixels
449
657
  if (originalValue.includes('calc(') && /^-?(?:\d+|\d*\.\d+)(pt|pc|in|cm|mm|q)$/i.test(result)) {
@@ -456,29 +664,33 @@ function normalizeMathFunctions (value, property, originalValue = '') {
456
664
  }
457
665
  }
458
666
 
459
- // Round results with excessive decimal places (4+ digits after the decimal)
667
+ // Round a result whose decimal places run past the budget a resolved number is written within
460
668
  result = result.replace(/(-?(?:\d+|\d*\.\d+)\.\d{4,})([a-z%]+)/gi, (match, number, unit) => {
461
- return roundCompactNumber(number) + unit;
669
+ return formatResolvedNumber(number) + unit;
462
670
  });
463
671
  return result;
464
672
  }
465
673
 
466
674
  /**
467
- * Simplifies a standalone calc() value by flattening nested calc expressions, converting absolute length units to pixels, and folding constant terms.
675
+ * Simplifies a standalone calc() value by resolving its arithmetic, flattening nested calc expressions, converting absolute length units to pixels, and folding constant terms.
468
676
  *
469
- * @param {string} value The CSS value string that may be a standalone calc() expression.
677
+ * @param {string} value The CSS value string, already known to be a single `calc()`.
470
678
  * @return {string} The simplified value, or the original value if simplification is not applicable.
471
679
  */
472
- function simplifyStandaloneCalc (value) {
473
- // Check if value starts with calc( and ends with )
474
- if (!/^calc\(/i.test(value) || !value.endsWith(')')) {
475
- return value;
476
- }
680
+ function foldStandaloneCalc (value) {
477
681
  let inner = value.slice(5, -1).trim();
478
682
 
479
683
  // Preserve percent-times-number expressions, only stripping whitespace around operators
480
- if (/^(?:-?(?:\d*\.\d+|\d+)%\s*\*\s*-?(?:\d*\.\d+|\d+)|-?(?:\d*\.\d+|\d+)\s*\*\s*-?(?:\d*\.\d+|\d+)%)$/i.test(inner.replace(/\s+/g, ' ').trim())) {
481
- return 'calc(' + inner.replace(/\s*([*/])\s*/g, '$1') + ')';
684
+ if (PERCENTAGE_MULTIPLICATION.test(inner.replace(/\s+/g, ' ').trim())) {
685
+ return compactCalculation(inner);
686
+ }
687
+
688
+ // Resolve the calculation as it was written, which is the only reading of it
689
+ // that keeps the grouping its parentheses state. Everything below reads a
690
+ // flattened copy of the expression, where that grouping is already gone.
691
+ const resolved = simplifyCalc(value);
692
+ if (resolved !== null) {
693
+ return chooseResolvedCalculation(resolved, compactCalculation(inner));
482
694
  }
483
695
 
484
696
  // If no nested function calls and no multiplication/division involving parens, try flattening
@@ -515,14 +727,28 @@ function simplifyStandaloneCalc (value) {
515
727
  }
516
728
 
517
729
  // Collapse whitespace and check for percent-division expressions
518
- const compactInner = inner.replace(/\s+/g, ' ').trim();
519
- // Preserve percent/number division, just strip whitespace around the operator
520
- if (/^\d+(?:\.\d+)?%\s*\/\s*\d+(?:\.\d+)?$/i.test(compactInner)) {
521
- return 'calc(' + compactInner.replace(/\s*\/\s*/g, '/') + ')';
730
+ const dividedPercentage = resolvePercentageDivision(inner.replace(/\s+/g, ' ').trim());
731
+ if (dividedPercentage) {
732
+ return dividedPercentage;
522
733
  }
523
734
 
524
- // Default: strip whitespace around multiplication/division operators
525
- return 'calc(' + compactInner.replace(/\s*([*/])\s*/g, '$1') + ')';
735
+ return compactCalculation(inner);
736
+ }
737
+
738
+ /**
739
+ * Simplifies a value that is a single `calc()`, holding whatever it resolves
740
+ * to inside the range the property it is declared on accepts.
741
+ *
742
+ * @param {string} value The CSS value string that may be a standalone calc() expression.
743
+ * @param {string} property The CSS property the value is declared on.
744
+ * @return {string} The simplified value, or the original value if simplification is not applicable.
745
+ */
746
+ function simplifyStandaloneCalc (value, property = '') {
747
+ // Check if value starts with calc( and ends with )
748
+ if (!/^calc\(/i.test(value) || !value.endsWith(')')) {
749
+ return value;
750
+ }
751
+ return clampResolvedValueToPropertyRange(foldStandaloneCalc(value), property);
526
752
  }
527
753
 
528
754
  export {
@@ -37,6 +37,7 @@ import {
37
37
  import {
38
38
  collapseShorthandParts,
39
39
  convertAbsoluteLengthToPx,
40
+ formatResolvedNumber,
40
41
  hasSubstitutedParts,
41
42
  normalizeScaleComponent,
42
43
  parseAlphaString,
@@ -1376,7 +1377,7 @@ function computeMinifiedValue (declaration) {
1376
1377
  // Remove whitespace around * and / operators (safe outside calc context)
1377
1378
  val = val.replace(/\s*([*/])\s*/g, '$1');
1378
1379
  val = normalizeMathFunctions(val, declaration.property, declaration.value || '');
1379
- val = simplifyStandaloneCalc(val);
1380
+ val = simplifyStandaloneCalc(val, declaration.property);
1380
1381
  // Simplify calc() expressions containing zero-percent additive terms
1381
1382
  val = val.replace(/calc\(([^()]+)\)/gi, (match, inner) => {
1382
1383
  // Collapse whitespace inside calc expression
@@ -1400,10 +1401,11 @@ function computeMinifiedValue (declaration) {
1400
1401
  }
1401
1402
  val = val.replace(/(^|\s|,|\()(-?)0+(\.\d+)/g, '$1$2$3'); // e.g. 0.5 -> .5, -0.5 -> -.5
1402
1403
 
1403
- // If value is a standalone number with optional unit, round it compactly
1404
+ // If value is a standalone number with optional unit, round it into the
1405
+ // character budget a number is written within
1404
1406
  if (/^[+-]?(?:\d+|\d*\.\d+)([a-z%]+)?$/i.test(val)) {
1405
1407
  const [, rawNumber, rawUnit = ''] = val.match(/^([+-]?(?:\d+|\d*\.\d+))([a-z%]+)?$/i);
1406
- val = roundCompactNumber(rawNumber, 4) + rawUnit;
1408
+ val = formatResolvedNumber(rawNumber) + rawUnit;
1407
1409
  }
1408
1410
 
1409
1411
  val = lowercaseHexColors(val);