postcss-convert-values
Version:
Convert values with PostCSS (e.g. ms -> s)
302 lines (275 loc) • 7.65 kB
JavaScript
/**
* @typedef {'length' | 'time' | 'angle' | 'frequency'} ConvertOption
* @typedef {{time?: boolean, length?: boolean, angle?: boolean, frequency?: boolean}} ConvertOptions
* @typedef {{
* option?: ConvertOption,
* factors: Map<string, number>,
* sourceOnly?: Set<string>,
* zeroOnly?: Map<string, string>,
* requiresZeroUnit?: boolean
* }} UnitGroup
*/
/**
* Each group converts only among its own units. `option` is the ConvertOptions
* key that disables the group; a group without one always converts. Metric
* units relate by exact decimal factors, so `length: false` leaves them
* converting. `q` is newer than the CSS 1 lengths and is never a target.
* `requiresZeroUnit` marks types where a unitless 0 is invalid.
*
* @type {UnitGroup[]}
*/
const unitGroups = [
{
option: 'length',
factors: new Map([
['in', 288],
['px', 3],
['pt', 4],
['pc', 48],
]),
},
{
factors: new Map([
['cm', 40],
['mm', 4],
['q', 1],
]),
sourceOnly: new Set(['q']),
},
{
option: 'time',
factors: new Map([
['s', 1000],
['ms', 1],
]),
requiresZeroUnit: true,
},
{
option: 'angle',
factors: new Map([
['deg', 10],
['turn', 3600],
['grad', 9],
]),
// `rad` converts only at zero, since no exact decimal factor exists.
zeroOnly: new Map([['rad', '0deg']]),
requiresZeroUnit: true,
},
{
option: 'frequency',
factors: new Map([
['khz', 1000],
['hz', 1],
]),
requiresZeroUnit: true,
},
];
// A unit in two groups would have two conversion tables, so building the map throws instead.
/** @type {Map<string, UnitGroup>} */
const unitGroupByUnit = new Map();
for (const group of unitGroups) {
for (const unit of [
...group.factors.keys(),
...(group.zeroOnly?.keys() ?? []),
]) {
if (unitGroupByUnit.has(unit)) {
throw new Error(
`Unit "${unit}" belongs to more than one conversion group`
);
}
unitGroupByUnit.set(unit, group);
}
}
/**
* @param {string} lowerCaseUnit
* @return {boolean}
*/
export function requiresZeroUnit(lowerCaseUnit) {
return unitGroupByUnit.get(lowerCaseUnit)?.requiresZeroUnit === true;
}
/**
* Accurately round a number to a fixed decimal precision without IEEE-754 binary
* floating point multiplication errors.
*
* @param {number} value
* @param {number} precision
* @return {number}
*/
export function roundToPrecision(value, precision) {
if (
typeof precision !== 'number' ||
precision < 0 ||
!Number.isFinite(precision) ||
!Number.isFinite(value)
) {
return value;
}
const p = Math.floor(precision);
const [mantissa, exponent = 0] = String(value).toLowerCase().split('e');
const shifted = Number(mantissa + 'e' + (Number(exponent) + p));
const rounded = (Math.sign(shifted) || 0) * Math.round(Math.abs(shifted));
return Number(rounded + 'e-' + p);
}
/**
* Compute the shortest exact scientific notation for a number if one exists.
*
* @param {number} num
* @return {string | undefined}
*/
export function toCompactExponent(num) {
if (num === 0 || !Number.isFinite(num)) {
return undefined;
}
const abs = Math.abs(num);
if (Number.isInteger(num) && abs > Number.MAX_SAFE_INTEGER) {
return undefined;
}
const sign = num < 0 ? '-' : '';
const expStr = abs.toExponential();
const eIndex = expStr.indexOf('e');
const mantissaStr = expStr.slice(0, eIndex);
const baseExp = Number(expStr.slice(eIndex + 1));
const dotIndex = mantissaStr.indexOf('.');
if (dotIndex === -1) {
const candidate = sign + mantissaStr + 'e' + baseExp;
return Number(candidate) === num ? candidate : undefined;
}
const digits =
mantissaStr.slice(0, dotIndex) + mantissaStr.slice(dotIndex + 1);
const decPlaces = mantissaStr.length - dotIndex - 1;
const candidate1 = sign + digits + 'e' + (baseExp - decPlaces);
if (Number(candidate1) === num) {
return candidate1;
}
const candidate2 = sign + mantissaStr + 'e' + baseExp;
if (Number(candidate2) === num) {
return candidate2;
}
return undefined;
}
/**
* @param {number} number
* @return {string}
*/
export function dropLeadingZero(number) {
const value = String(number);
if (number % 1) {
if (value[0] === '0') {
return value.slice(1);
}
if (value[0] === '-' && value[1] === '0') {
return '-' + value.slice(2);
}
}
return value;
}
/**
* Format a number using standard decimal or compact exponent notation.
*
* @param {number} number
* @param {boolean} [allowExponent=true]
* @return {string}
*/
export function formatNumber(number, allowExponent = true) {
const decimal = dropLeadingZero(number);
if (!allowExponent) {
return decimal;
}
const abs = Math.abs(number);
if (abs >= 0.001 && abs < 1000) {
return decimal;
}
if (Number.isInteger(number) && number % 1000 !== 0) {
return decimal;
}
const exp = toCompactExponent(number);
if (exp && exp.length < decimal.length) {
return exp;
}
return decimal;
}
/**
* @param {number} number
* @param {string} originalUnit
* @param {UnitGroup} group
* @return {string}
*/
function findShortestConversion(number, originalUnit, group) {
const base = number * /** @type {number} */ (group.factors.get(originalUnit));
let shortest = '';
for (const [u, factor] of group.factors) {
if (u === originalUnit || group.sourceOnly?.has(u)) {
continue;
}
const convertedNumber = Number((base / factor).toPrecision(15));
const value = formatNumber(convertedNumber) + u;
if (!shortest || value.length < shortest.length) {
shortest = value;
}
}
return shortest;
}
/**
* Called only for units whose conversion is enabled by the options.
*
* @param {number} number
* @param {string} lowerCaseUnit
* @param {UnitGroup | undefined} group
* @return {string | undefined}
*/
function findConverted(number, lowerCaseUnit, group) {
if (!group) {
return undefined;
}
const zeroText = group.zeroOnly?.get(lowerCaseUnit);
if (zeroText !== undefined) {
return number === 0 ? zeroText : undefined;
}
return findShortestConversion(number, lowerCaseUnit, group);
}
/**
* @param {string} converted
* @param {string} value
* @param {string} decimalValue
* @param {string} lowerCaseUnit
* @return {boolean}
*/
function shouldUseConverted(converted, value, decimalValue, lowerCaseUnit) {
if (converted.length < value.length) {
return true;
}
if (lowerCaseUnit === 'rad' && converted.length <= value.length) {
return true;
}
return (
converted.length === value.length &&
!converted.includes('e') &&
value.includes('e') &&
converted.length < decimalValue.length
);
}
/**
* @param {number} number
* @param {string} unit
* @param {ConvertOptions} [options]
* @return {string}
*/
const convert = function (number, unit, options = {}) {
const lowerCaseUnit = unit.toLowerCase();
const group = unitGroupByUnit.get(lowerCaseUnit);
if (group?.option !== undefined && options[group.option] === false) {
// Disabling conversion must not disable exponent formatting, so 1e-6s stays short.
return formatNumber(number) + (unit ? unit : '');
}
const converted = findConverted(number, lowerCaseUnit, group);
const decimalValue = dropLeadingZero(number) + (unit ? unit : '');
const value = formatNumber(number) + (unit ? unit : '');
if (
converted &&
shouldUseConverted(converted, value, decimalValue, lowerCaseUnit)
) {
return converted;
}
return value;
};
export default convert;