diff --git a/packages/block-editor/src/utils/parse-css-unit-to-px.js b/packages/block-editor/src/utils/parse-css-unit-to-px.js new file mode 100644 index 00000000000000..c8451fcc3e1d65 --- /dev/null +++ b/packages/block-editor/src/utils/parse-css-unit-to-px.js @@ -0,0 +1,230 @@ +/** + * Converts string to object { value, unit }. + * + * @param {string} cssUnit + * @return {Object} parsedUnit + */ +function parseUnit( cssUnit ) { + const match = cssUnit + ?.trim() + .match( + /^(0?[-.]?\d+)(r?e[m|x]|v[h|w|min|max]+|p[x|t|c]|[c|m]m|%|in|ch|Q|lh)$/ + ); + if ( ! isNaN( cssUnit ) && ! isNaN( parseFloat( cssUnit ) ) ) { + return { value: parseFloat( cssUnit ), unit: 'px' }; + } + return match + ? { value: parseFloat( match[ 1 ] ) || match[ 1 ], unit: match[ 2 ] } + : { value: cssUnit, unit: undefined }; +} +/** + * Evaluate a math expression. + * + * @param {string} expression + * @return {number} evaluated expression. + */ +function calculate( expression ) { + return Function( `'use strict'; return (${ expression })` )(); +} + +/** + * Calculates the css function value for the supported css functions such as max, min, clamp and calc. + * + * @param {string} functionUnitValue string should be in a particular format (for example min(12px,12px) ) no nested loops. + * @param {Object} options + * @return {string} unit containing the unit in PX. + */ +function getFunctionUnitValue( functionUnitValue, options ) { + const functionUnit = functionUnitValue.split( /[(),]/g ).filter( Boolean ); + + const units = functionUnit + .slice( 1 ) + .map( ( unit ) => parseUnit( getPxFromCssUnit( unit, options ) ).value ) + .filter( Boolean ); + + switch ( functionUnit[ 0 ] ) { + case 'min': + return Math.min( ...units ) + 'px'; + case 'max': + return Math.max( ...units ) + 'px'; + case 'clamp': + if ( units.length !== 3 ) { + return null; + } + if ( units[ 1 ] < units[ 0 ] ) { + return units[ 0 ] + 'px'; + } + if ( units[ 1 ] > units[ 2 ] ) { + return units[ 2 ] + 'px'; + } + return units[ 1 ] + 'px'; + case 'calc': + return units[ 0 ] + 'px'; + } +} + +/** + * Take a css function such as min, max, calc, clamp and returns parsedUnit + * + * How this works for the nested function is that it first replaces the inner function call. + * Then it tackles the outer onces. + * So for example: min( max(25px, 35px), 40px ) + * in the first pass we would replace max(25px, 35px) with 35px. + * then we would try to evaluate min( 35px, 40px ) + * and then finally return 35px. + * + * @param {string} cssUnit + * @return {Object} parsedUnit object. + */ +function parseUnitFunction( cssUnit ) { + while ( true ) { + const currentCssUnit = cssUnit; + const regExp = /(max|min|calc|clamp)\(([^()]*)\)/g; + const matches = regExp.exec( cssUnit ) || []; + if ( matches[ 0 ] ) { + const functionUnitValue = getFunctionUnitValue( matches[ 0 ] ); + cssUnit = cssUnit.replace( matches[ 0 ], functionUnitValue ); + } + + // if the unit hasn't been modified or we have a single value break free. + if ( cssUnit === currentCssUnit || parseFloat( cssUnit ) ) { + break; + } + } + + return parseUnit( cssUnit ); +} +/** + * Return true if we think this is a math expression. + * + * @param {string} cssUnit the cssUnit value being evaluted. + * @return {boolean} Whether the cssUnit is a math expression. + */ +function isMathExpression( cssUnit ) { + for ( let i = 0; i < cssUnit.length; i++ ) { + if ( [ '+', '-', '/', '*' ].includes( cssUnit[ i ] ) ) { + return true; + } + } + return false; +} +/** + * Evaluates the math expression and return a px value. + * + * @param {string} cssUnit the cssUnit value being evaluted. + * @return {string} return a converfted value to px. + */ +function evalMathExpression( cssUnit ) { + let errorFound = false; + // Convert every part of the expression to px values. + const cssUnitsBits = cssUnit.split( /[+-/*/]/g ).filter( Boolean ); + for ( const unit of cssUnitsBits ) { + // Standardize the unit to px and extract the value. + const parsedUnit = parseUnit( getPxFromCssUnit( unit ) ); + if ( ! parseFloat( parsedUnit.value ) ) { + errorFound = true; + // end early since we are dealing with a null value. + break; + } + cssUnit = cssUnit.replace( unit, parsedUnit.value ); + } + + return errorFound ? null : calculate( cssUnit ).toFixed( 0 ) + 'px'; +} +/** + * Convert a parsedUnit object to px value. + * + * @param {Object} parsedUnit + * @param {Object} options + * @return {string} or {null} returns the converted with in a px value format. + */ +function convertParsedUnitToPx( parsedUnit, options ) { + const PIXELS_PER_INCH = 96; + const ONE_PERCENT = 0.01; + + const defaultProperties = { + fontSize: 16, + lineHeight: 16, + width: 375, + height: 812, + type: 'font', + }; + + const setOptions = Object.assign( {}, defaultProperties, options ); + + const relativeUnits = { + em: setOptions.fontSize, + rem: setOptions.fontSize, + vh: setOptions.height * ONE_PERCENT, + vw: setOptions.width * ONE_PERCENT, + vmin: + ( setOptions.width < setOptions.height + ? setOptions.width + : setOptions.height ) * ONE_PERCENT, + vmax: + ( setOptions.width > setOptions.height + ? setOptions.width + : setOptions.height ) * ONE_PERCENT, + '%': + ( setOptions.type === 'font' + ? setOptions.fontSize + : setOptions.width ) * ONE_PERCENT, + ch: 8, // The advance measure (width) of the glyph "0" of the element's font. Approximate + ex: 7.15625, // x-height of the element's font. Approximate + lh: setOptions.lineHeight, + }; + + const absoluteUnits = { + in: PIXELS_PER_INCH, + cm: PIXELS_PER_INCH / 2.54, + mm: PIXELS_PER_INCH / 25.4, + pt: PIXELS_PER_INCH / 72, + pc: PIXELS_PER_INCH / 6, + px: 1, + Q: PIXELS_PER_INCH / 2.54 / 40, + }; + + if ( relativeUnits[ parsedUnit.unit ] ) { + return ( + ( relativeUnits[ parsedUnit.unit ] * parsedUnit.value ).toFixed( + 0 + ) + 'px' + ); + } + + if ( absoluteUnits[ parsedUnit.unit ] ) { + return ( + ( absoluteUnits[ parsedUnit.unit ] * parsedUnit.value ).toFixed( + 0 + ) + 'px' + ); + } + + return null; +} +/** + * Returns the px value of a cssUnit. + * + * @param {string} cssUnit + * @param {string} options + * @return {string} returns the cssUnit value in a simple px format. + */ +export function getPxFromCssUnit( cssUnit, options = {} ) { + if ( Number.isFinite( cssUnit ) ) { + return cssUnit.toFixed( 0 ) + 'px'; + } + if ( cssUnit === undefined ) { + return null; + } + let parsedUnit = parseUnit( cssUnit ); + + if ( ! parsedUnit.unit ) { + parsedUnit = parseUnitFunction( cssUnit, options ); + } + + if ( isMathExpression( cssUnit ) && ! parsedUnit.unit ) { + return evalMathExpression( cssUnit ); + } + + return convertParsedUnitToPx( parsedUnit, options ); +} diff --git a/packages/block-editor/src/utils/test/parse-css-unit-to-px.js b/packages/block-editor/src/utils/test/parse-css-unit-to-px.js new file mode 100644 index 00000000000000..90cfac3b55e27b --- /dev/null +++ b/packages/block-editor/src/utils/test/parse-css-unit-to-px.js @@ -0,0 +1,183 @@ +/** + * Internal dependencies + */ +import { getPxFromCssUnit } from '../parse-css-unit-to-px'; + +describe( 'getPxFromCssUnit', () => { + // Absolute units + it( 'test px return px unit', () => { + expect( getPxFromCssUnit( '25px' ) ).toBe( '25px' ); + } ); + + it( 'test numeric float return px unit', () => { + expect( getPxFromCssUnit( '25.5' ) ).toBe( '26px' ); + } ); + + it( 'test cm return px unit', () => { + expect( getPxFromCssUnit( '1cm' ) ).toBe( '38px' ); + } ); + + it( 'test mm return px unit', () => { + expect( getPxFromCssUnit( '10mm' ) ).toBe( '38px' ); + } ); + + it( 'test in return px unit', () => { + expect( getPxFromCssUnit( '1in' ) ).toBe( '96px' ); + } ); + + it( 'test pt return px unit', () => { + expect( getPxFromCssUnit( '12pt' ) ).toBe( '16px' ); + } ); + + it( 'test pc return px unit', () => { + expect( getPxFromCssUnit( '1pc' ) ).toBe( '16px' ); + } ); + + it( 'test Q return px unit', () => { + expect( getPxFromCssUnit( '40Q' ) ).toBe( '38px' ); // 40 Q should be 1 cm + } ); + + // Relative units + it( 'test em return px unit', () => { + expect( getPxFromCssUnit( '2em', { fontSize: 10 } ) ).toBe( '20px' ); + } ); + + it( 'test rem return px unit', () => { + expect( getPxFromCssUnit( '2rem', { fontSize: 10 } ) ).toBe( '20px' ); + } ); + + it( 'test vw return px unit', () => { + expect( getPxFromCssUnit( '20vw', { width: 100 } ) ).toBe( '20px' ); + } ); + + it( 'test vh return px unit', () => { + expect( getPxFromCssUnit( '20vh', { height: 200 } ) ).toBe( '40px' ); + } ); + + it( 'test vmin return px unit', () => { + expect( + getPxFromCssUnit( '20vmin', { height: 200, width: 100 } ) + ).toBe( '20px' ); + } ); + + it( 'test vmax return px unit', () => { + expect( + getPxFromCssUnit( '20vmax', { height: 200, width: 100 } ) + ).toBe( '40px' ); + } ); + + it( 'test lh return px unit', () => { + expect( getPxFromCssUnit( '20lh', { lineHeight: 2 } ) ).toBe( '40px' ); + } ); + + it( 'test % return px unit', () => { + expect( + getPxFromCssUnit( '120%', { + height: 200, + width: 100, + fontSize: 10, + type: 'font', + } ) + ).toBe( '12px' ); + } ); + + // Function units + it( 'test min() return px unit', () => { + expect( getPxFromCssUnit( 'min(20px, 25px)' ) ).toBe( '20px' ); + } ); + + it( 'test min() function with many arguments return px unit', () => { + expect( getPxFromCssUnit( 'min(20px, 9px, 12pt, 25px)' ) ).toBe( + '9px' + ); + } ); + + it( 'test max() return px unit', () => { + expect( getPxFromCssUnit( 'max(20px, 25px)' ) ).toBe( '25px' ); + } ); + + it( 'test clamp() lower return px unit', () => { + expect( getPxFromCssUnit( 'clamp(10px, 9px, 25px)' ) ).toBe( '10px' ); + } ); + + it( 'test clamp() upper return px unit', () => { + expect( getPxFromCssUnit( 'clamp(10px, 35px, 25px)' ) ).toBe( '25px' ); + } ); + + it( 'test clamp() middle return px unit', () => { + expect( getPxFromCssUnit( 'clamp(10px, 15px, 25px)' ) ).toBe( '15px' ); + } ); + + it( 'test nested max min function return px unit', () => { + expect( getPxFromCssUnit( 'min(max(20px,25px), 35px)' ) ).toBe( + '25px' + ); + } ); + + it( 'test nested min max function return px unit', () => { + expect( getPxFromCssUnit( 'max(min(20px,25px), 35px)' ) ).toBe( + '35px' + ); + } ); + + it( 'test calculate function return px unit', () => { + expect( getPxFromCssUnit( '10px + 25px' ) ).toBe( '35px' ); + } ); + + it( 'test calc(10px + 25px) function return px unit', () => { + expect( getPxFromCssUnit( 'calc(10px + 25px)' ) ).toBe( '35px' ); + } ); + + it( 'test calc( number * cssUnit ) return px unit', () => { + expect( getPxFromCssUnit( 'calc( 2 * 20px)' ) ).toBe( '40px' ); + } ); + + it( 'test calc(25px - 10px) function return px unit', () => { + expect( getPxFromCssUnit( 'calc(25px - 10px)' ) ).toBe( '15px' ); + } ); + + it( 'test min(10px + 25px, 55pt) function return px unit', () => { + expect( getPxFromCssUnit( 'min(10px + 25px, 55pt)' ) ).toBe( '35px' ); + } ); + + it( 'test calc(12vw * 10px) function return px unit', () => { + expect( getPxFromCssUnit( 'calc(12vw * 10px)' ) ).toBe( '450px' ); + } ); + + it( 'test calc(42vw / 10px) function return px unit', () => { + expect( getPxFromCssUnit( 'calc(45vw / 10px)' ) ).toBe( '17px' ); + } ); + + it( 'test empty string', () => { + expect( getPxFromCssUnit( '' ) ).toBe( null ); + } ); + + it( 'test undefined string', () => { + expect( getPxFromCssUnit( undefined ) ).toBe( null ); + } ); + it( 'test integer string', () => { + expect( getPxFromCssUnit( 123 ) ).toBe( '123px' ); + } ); + + it( 'test float string', () => { + expect( getPxFromCssUnit( 123.456 ) ).toBe( '123px' ); + } ); + + it( 'test text string', () => { + expect( getPxFromCssUnit( 'abc' ) ).toBe( null ); + } ); + + it( 'test not non function return null', () => { + expect( getPxFromCssUnit( 'abc + num' ) ).toBe( null ); + } ); + + it( 'test not a fishy function return null', () => { + expect( getPxFromCssUnit( 'console.log("howdy"); + 10px' ) ).toBe( + null + ); + } ); + + it( 'test not a typo function return null', () => { + expect( getPxFromCssUnit( 'calc(12vw * 10px' ) ).toBe( null ); + } ); +} );