/** * util */ import { TokenType, tokenize } from '@csstools/css-tokenizer'; import { CacheItem, createCacheKey, getCache, setCache } from './cache'; import { isString } from './common'; import { Options } from './typedef'; /* constants */ const { CloseParen: PAREN_CLOSE, Comma: COMMA, Comment: COMMENT, Delim: DELIM, EOF, Function: FUNC, OpenParen: PAREN_OPEN, Whitespace: W_SPACE } = TokenType; const NAMESPACE = 'util'; /* numeric constants */ const DEC = 10; const HEX = 16; const DEG = 360; const DEG_HALF = 180; /* regexp */ const REG_DASHED_IDENT = /--[\w-]+/g; const REG_COMMA = /^,$/; const REG_SLASH = /^\/$/; const REG_WHITESPACE = /^\s+$/; /** * split value * NOTE: comments are stripped, it can be preserved if, in the options param, * `delimiter` is either ',' or '/' and with `preserveComment` set to `true` * @param value - CSS value * @param [opt] - options * @returns array of values */ export const splitValue = (value: string, opt: Options = {}): string[] => { if (!isString(value)) { throw new TypeError(`${value} is not a string.`); } const strValue = value.trim(); const { delimiter = ' ', preserveComment = false } = opt; const cacheKey: string = createCacheKey( { namespace: NAMESPACE, name: 'splitValue', value: strValue }, { delimiter, preserveComment } ); const cachedResult = getCache(cacheKey); if (cachedResult instanceof CacheItem) { return cachedResult.item as string[]; } let regDelimiter; switch (delimiter) { case ',': { regDelimiter = REG_COMMA; break; } case '/': { regDelimiter = REG_SLASH; break; } default: { regDelimiter = REG_WHITESPACE; } } const tokens = tokenize({ css: strValue }); let nest = 0; let currentStr = ''; const res: string[] = []; for (const [type, val] of tokens) { switch (type) { case COMMA: case DELIM: { if (nest === 0 && regDelimiter.test(val)) { res.push(currentStr.trim()); currentStr = ''; } else { currentStr += val; } break; } case COMMENT: { if (preserveComment && (delimiter === ',' || delimiter === '/')) { currentStr += val; } break; } case FUNC: case PAREN_OPEN: { currentStr += val; nest++; break; } case PAREN_CLOSE: { currentStr += val; nest--; break; } case W_SPACE: { if (regDelimiter.test(val)) { if (nest === 0) { if (currentStr) { res.push(currentStr.trim()); currentStr = ''; } } else { currentStr += ' '; } } else if (!currentStr.endsWith(' ')) { currentStr += ' '; } break; } default: { if (type === EOF) { res.push(currentStr.trim()); currentStr = ''; } else { currentStr += val; } } } } setCache(cacheKey, res); return res; }; /** * extract dashed-ident tokens * @param value - CSS value * @returns array of dashed-ident tokens */ export const extractDashedIdent = (value: string): string[] => { if (!isString(value)) { throw new TypeError(`${value} is not a string.`); } const strValue = value.trim(); const cacheKey: string = createCacheKey({ namespace: NAMESPACE, name: 'extractDashedIdent', value: strValue }); const cachedResult = getCache(cacheKey); if (cachedResult instanceof CacheItem) { return cachedResult.item as string[]; } const matches = strValue.match(REG_DASHED_IDENT); const res = matches ? [...new Set(matches)] : []; setCache(cacheKey, res); return res; }; /** * round to specified precision * @param value - numeric value * @param bit - minimum bits * @returns rounded value */ export const roundToPrecision = (value: number, bit: number = 0): number => { if (!Number.isFinite(value)) { throw new TypeError(`${value} is not a finite number.`); } if (!Number.isFinite(bit)) { throw new TypeError(`${bit} is not a finite number.`); } if (bit < 0 || bit > HEX) { throw new RangeError(`${bit} is not between 0 and ${HEX}.`); } if (bit === 0) { return Math.round(value); } const precision = bit === HEX ? 6 : bit < DEC ? 4 : 5; return parseFloat(value.toPrecision(precision)); }; /** * interpolate hue * @param hueA - hue value * @param hueB - hue value * @param arc - shorter | longer | increasing | decreasing * @returns result - [hueA, hueB] */ export const interpolateHue = ( hueA: number, hueB: number, arc: string = 'shorter' ): [number, number] => { if (!Number.isFinite(hueA)) { throw new TypeError(`${hueA} is not a finite number.`); } if (!Number.isFinite(hueB)) { throw new TypeError(`${hueB} is not a finite number.`); } let a = hueA; let b = hueB; switch (arc) { case 'decreasing': { if (b > a) { a += DEG; } break; } case 'increasing': { if (b < a) { b += DEG; } break; } case 'longer': { if (b > a && b < a + DEG_HALF) { a += DEG; } else if (b > a - DEG_HALF && b <= a) { b += DEG; } break; } case 'shorter': default: { if (b > a + DEG_HALF) { a += DEG; } else if (b < a - DEG_HALF) { b += DEG; } } } return [a, b]; }; /* absolute font size to pixel ratio */ const absoluteFontSize = new Map([ ['xx-small', 9 / 16], ['x-small', 5 / 8], ['small', 13 / 16], ['medium', 1], ['large', 9 / 8], ['x-large', 3 / 2], ['xx-large', 2], ['xxx-large', 3] ]); /* relative font size to pixel ratio */ const relativeFontSize = new Map([ ['smaller', 1 / 1.2], ['larger', 1.2] ]); /* absolute length to pixel ratio */ const absoluteLength = new Map([ ['cm', 96 / 2.54], ['mm', 96 / 25.4], ['q', 96 / 101.6], ['in', 96], ['pc', 16], ['pt', 96 / 72], ['px', 1] ]); /* relative length to pixel ratio */ const relativeLength = new Map([ ['rcap', 1], ['rch', 0.5], ['rem', 1], ['rex', 0.5], ['ric', 1], ['rlh', 1.2] ]); /** * resolve length in pixels * @param value - value * @param unit - unit * @param [opt] - options * @returns pixelated value */ export const resolveLengthInPixels = ( value: number | string, unit: string | undefined, opt: Options = {} ): number => { const { dimension = {} } = opt; const { callback, em, rem, vh, vw } = dimension as { callback: (K: string) => number; em: number; rem: number; vh: number; vw: number; }; if (isString(value)) { const str = value.toLowerCase().trim(); const ratio = absoluteFontSize.get(str); if (ratio !== undefined) { return ratio * rem; } const relRatio = relativeFontSize.get(str); if (relRatio !== undefined) { return relRatio * em; } return Number.NaN; } if (Number.isFinite(value) && unit) { const u = unit.toLowerCase(); if (Object.hasOwn(dimension, u)) { return value * Number(dimension[u]); } if (typeof callback === 'function') { return value * (callback(u) ?? Number.NaN); } const absRatio = absoluteLength.get(u); if (absRatio !== undefined) { return value * absRatio; } const relRatio = relativeLength.get(u); if (relRatio !== undefined) { return value * relRatio * rem; } const rUnitRatio = relativeLength.get(`r${u}`); if (rUnitRatio !== undefined) { return value * rUnitRatio * em; } switch (u) { case 'vb': { return value * vh; } case 'vi': { return value * vw; } case 'vmax': { return value * Math.max(vh, vw); } case 'vmin': { return value * Math.min(vh, vw); } default: } } // unsupported or invalid value return Number.NaN; };