parse css gradient rule with Javascript regex

Parsing CSS can be far more complex there are few things to remember:

  • Avoid writing a parser - someone else will probably have written one already (search).
  • Your parser will likely fail if you don't control the input source or test it thoroughly with input samples.
  • In the case of gradients you can have "angles" as well as "corner-sides" like "right".
  • There are an unknown number of color stops (minimum 1).
  • You will never likely want to include the complete list of CSS colors in a regular expression (e.g. red, blue, etc).
  • You should check out MDN for details of syntax variations, the sample code below only supports the standard syntax.
  • Regular expression support and bugs are different depending on the browser and version - test your target browsers with all your samples.

OK, so here is a crazy example of how you "could" parse the gradient using regular expressions - I'm not saying you should.

Here I build my regular expressions in code to keep some level of readability and maintainability of the code.

The final output of the test_this_thing functions console.log(result); is as follows:

Input:

background-image:linear-gradient(to right bottom, #FF0000 0%, #00FF00 20px, rgb(0, 0, 255) 100%);

Output:

{
   original:"to right bottom, #FF0000 0%, #00FF00 20px, rgb(0, 0, 255) 100%",
   line:"to right bottom",
   sideCorner:"right bottom",
   colorStopList:[
      {
         color:"#FF0000",
         position:"0%"
      },
      {
         color:"#00FF00",
         position:"20px"
      },
      {
         color:"rgb(0, 0, 255)",
         position:"100%"
      }
   ]
}

Note the output includes the original property - this looks like the input - but if part of the input wasn't matched the input and original values would be different; noting an possible error in the parser.

Here is a source:

/**
 * Utility combine multiple regular expressions.
 *
 * @param {RegExp[]|string[]} regexpList List of regular expressions or strings.
 * @param {string} flags Normal RegExp flags.
 */
var combineRegExp = function (regexpList, flags) {
    var i,
        source = '';
    for (i = 0; i < regexpList.length; i++) {
        if (typeof regexpList[i] === 'string') {
            source += regexpList[i];
        } else {
            source += regexpList[i].source;
        }
    }
    return new RegExp(source, flags);
};

/**
 * Generate the required regular expressions once.
 *
 * Regular Expressions are easier to manage this way and can be well described.
 *
 * @result {object} Object containing regular expressions.
 */
var generateRegExp = function () {
    // Note any variables with "Capture" in name include capturing bracket set(s).
    var searchFlags = 'gi', // ignore case for angles, "rgb" etc
        rAngle = /(?:[+-]?\d*\.?\d+)(?:deg|grad|rad|turn)/, // Angle +ive, -ive and angle types
        rSideCornerCapture = /to\s+((?:(?:left|right)(?:\s+(?:top|bottom))?))/, // optional 2nd part
        rComma = /\s*,\s*/, // Allow space around comma.
        rColorHex = /\#(?:[a-f0-9]{6}|[a-f0-9]{3})/, // 3 or 6 character form
        rDigits3 = /\(\s*(?:\d{1,3}\s*,\s*){2}\d{1,3}\s*\)/,// "(1, 2, 3)"
        rDigits4 = /\(\s*(?:\d{1,3}\s*,\s*){2}\d{1,3}\s*,\s*\d*\.?\d+\)/,// "(1, 2, 3, 4)"
        rValue = /(?:[+-]?\d*\.?\d+)(?:%|[a-z]+)?/,// ".9", "-5px", "100%".
        rKeyword = /[_a-z-][_a-z0-9-]*/,// "red", "transparent", "border-collapse".
        rColor = combineRegExp([
            '(?:', rColorHex, '|', '(?:rgb|hsl)', rDigits3, '|', '(?:rgba|hsla)', rDigits4, '|', rKeyword, ')'
        ], ''),
        rColorStop = combineRegExp([rColor, '(?:\\s+', rValue, '(?:\\s+', rValue, ')?)?'], ''),// Single Color Stop, optional %, optional length.
        rColorStopList = combineRegExp(['(?:', rColorStop, rComma, ')*', rColorStop], ''),// List of color stops min 1.
        rLineCapture = combineRegExp(['(?:(', rAngle, ')|', rSideCornerCapture, ')'], ''),// Angle or SideCorner
        rGradientSearch = combineRegExp([
            '(?:(', rLineCapture, ')', rComma, ')?(', rColorStopList, ')'
        ], searchFlags),// Capture 1:"line", 2:"angle" (optional), 3:"side corner" (optional) and 4:"stop list".
        rColorStopSearch = combineRegExp([
            '\\s*(', rColor, ')', '(?:\\s+', '(', rValue, '))?', '(?:', rComma, '\\s*)?'
        ], searchFlags);// Capture 1:"color" and 2:"position" (optional).

    return {
        gradientSearch: rGradientSearch,
        colorStopSearch: rColorStopSearch
    };
};

/**
 * Actually parse the input gradient parameters string into an object for reusability.
 *
 *
 * @note Really this only supports the standard syntax not historical versions, see MDN for details
 *       https://developer.mozilla.org/en-US/docs/Web/CSS/linear-gradient
 *
 * @param regExpLib
 * @param {string} input Input string in the form "to right bottom, #FF0 0%, red 20px, rgb(0, 0, 255) 100%"
 * @returns {object|undefined} Object containing break down of input string including array of stop points.
 */
var parseGradient = function (regExpLib, input) {
    var result,
        matchGradient,
        matchColorStop,
        stopResult;

    // reset search position, because we reuse regex.
    regExpLib.gradientSearch.lastIndex = 0;

    matchGradient = regExpLib.gradientSearch.exec(input);
    if (matchGradient !== null) {
        result = {
            original: matchGradient[0],
            colorStopList: []
        };

        // Line (Angle or Side-Corner).
        if (!!matchGradient[1]) {
            result.line = matchGradient[1];
        }
        // Angle or undefined if side-corner.
        if (!!matchGradient[2]) {
            result.angle = matchGradient[2];
        }
        // Side-corner or undefined if angle.
        if (!!matchGradient[3]) {
            result.sideCorner = matchGradient[3];
        }


        // reset search position, because we reuse regex.
        regExpLib.colorStopSearch.lastIndex = 0;

        // Loop though all the color-stops.
        matchColorStop = regExpLib.colorStopSearch.exec(matchGradient[4]);
        while (matchColorStop !== null) {

            stopResult = {
                color: matchColorStop[1]
            };

            // Position (optional).
            if (!!matchColorStop[2]) {
                stopResult.position = matchColorStop[2];
            }
            result.colorStopList.push(stopResult);

            // Continue searching from previous position.
            matchColorStop = regExpLib.colorStopSearch.exec(matchGradient[4]);
        }
    }

    // Can be undefined if match not found.
    return result;
};

var test_this_one = function (regExpLib, input) {
    var result,
        rGradientEnclosedInBrackets = /.*gradient\s*\(((?:\([^\)]*\)|[^\)\(]*)*)\)/,// Captures inside brackets - max one additional inner set.
        match = rGradientEnclosedInBrackets.exec(input);

    if (match !== null) {
        // Get the parameters for the gradient
        result = parseGradient(regExpLib, match[1]);
        if (result.original.trim() !== match[1].trim()) {
            // Did not match the input exactly - possible parsing error.
            result.parseWarning = true;
        }
    } else {
        result = "Failed to find gradient";
    }

    return result;
};

var test_this_thing = function () {

    var result = [],
        regExpLib = generateRegExp(),
        testSubjects = [
            // Original question sample
            'background-image:linear-gradient(to right bottom, #FF0000 0%, #00FF00 20px, rgb(0, 0, 255) 100%);',
            // Sample to test RGBA values (1)
            'background-image:linear-gradient(to right bottom, rgba(255, 0, 0, .1) 0%, rgba(0, 255, 0, 0.9) 20px);',
            // Sample to test optional gradient line
            'background-image:linear-gradient(#FF0000 0%, #00FF00 20px, rgb(0, 0, 255) 100%);',
            // Angle, named colors
            'background: linear-gradient(45deg, red, blue);',
            // Gradient that starts at 60% of the gradient line
            'background: linear-gradient(135deg, orange, orange 60%, cyan);',
            // Gradient with multi-position color stops
            'background: linear-gradient(to right, red 20%, orange 20% 40%, yellow 40% 60%, green 60% 80%, blue 80%);'
        ];
    for (var i = 0; i < testSubjects.length; i++) {
        result.push(test_this_one(regExpLib, testSubjects[i]));
    }

    console.log(result);
};
test_this_thing();

Thanks for sharing some great time saving code. I noticed an error on the following line, which doesn't allow decimal opacity:

rDigits4 = /(\s*(?:[0-9]{1,3}\s*,\s*){3}[0-9]{1,3}\s*)/,// "(1, 2, 3, 4)"

I recommend using this instead, which also supports decimal values with more than 3 numbers:

rDigits4 = /(\s*(?:[0-9]{1,3}\s*,\s*){3}(?:[.\d]+)\s*)/,// "(1, 2, 3, .4)"


This parser from Rafael Caricio seems to work well, handling both linear and radial gradients.

Tested successful on the gradients listed below, most of which came from the wonderful solution from @DeanTaylor. The only problem with Dean's solution is the inability to handle radial gradients.

One gradient the parser chokes on is: radial-gradient(at 57% 50%, rgb(102, 126, 234) 0%, rgb(118, 75, 162) 100%)

Tested Gradients:

  • linear-gradient(to right bottom, #FF0000 0%, #00FF00 20px, rgb(0, 0, 255) 100%)
  • linear-gradient(to right bottom, rgba(255, 0, 0, .1) 0%, rgba(0, 255, 0, 0.9) 20px)
  • radial-gradient(rgb(102, 126, 234), rgb(118, 75, 162))
  • linear-gradient(#FF0000 0%, #00FF00 20px, rgb(0, 0, 255) 100%)
  • linear-gradient(45deg, red, blue)
  • linear-gradient(135deg, orange, orange 60%, cyan)
  • linear-gradient(to right, red 20%, orange 20% 40%, yellow 40% 60%, green 60% 80%, blue 80%)
  • radial-gradient(rgb(102, 126, 234), rgb(118, 75, 162))
  • radial-gradient(circle at 100%, #333, #333 50%, #eee 75%, #333 75%)
  • radial-gradient(ellipse farthest-side at 16% 35%, #ff0000 0%, #00ff00 80%)
  • radial-gradient(circle farthest-side at 28% 50%, #ff0000 0%, #00ff00 80%)
  • radial-gradient(circle farthest-corner at 28% 50%, #ff0000 0%, #00ff00 80%)

Code:

// Copyright (c) 2014 Rafael Caricio. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

var GradientParser = (GradientParser || {});

GradientParser.parse = (function() {

  var tokens = {
    linearGradient: /^(\-(webkit|o|ms|moz)\-)?(linear\-gradient)/i,
    repeatingLinearGradient: /^(\-(webkit|o|ms|moz)\-)?(repeating\-linear\-gradient)/i,
    radialGradient: /^(\-(webkit|o|ms|moz)\-)?(radial\-gradient)/i,
    repeatingRadialGradient: /^(\-(webkit|o|ms|moz)\-)?(repeating\-radial\-gradient)/i,
    sideOrCorner: /^to (left (top|bottom)|right (top|bottom)|left|right|top|bottom)/i,
    extentKeywords: /^(closest\-side|closest\-corner|farthest\-side|farthest\-corner|contain|cover)/,
    positionKeywords: /^(left|center|right|top|bottom)/i,
    pixelValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))px/,
    percentageValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))\%/,
    emValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))em/,
    angleValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))deg/,
    startCall: /^\(/,
    endCall: /^\)/,
    comma: /^,/,
    hexColor: /^\#([0-9a-fA-F]+)/,
    literalColor: /^([a-zA-Z]+)/,
    rgbColor: /^rgb/i,
    rgbaColor: /^rgba/i,
    number: /^(([0-9]*\.[0-9]+)|([0-9]+\.?))/
  };

  var input = '';

  function error(msg) {
    var err = new Error(input + ': ' + msg);
    err.source = input;
    throw err;
  }

  function getAST() {
    var ast = matchListDefinitions();

    if (input.length > 0) {
      error('Invalid input not EOF');
    }

    return ast;
  }

  function matchListDefinitions() {
    return matchListing(matchDefinition);
  }

  function matchDefinition() {
    return matchGradient(
            'linear-gradient',
            tokens.linearGradient,
            matchLinearOrientation) ||

          matchGradient(
            'repeating-linear-gradient',
            tokens.repeatingLinearGradient,
            matchLinearOrientation) ||

          matchGradient(
            'radial-gradient',
            tokens.radialGradient,
            matchListRadialOrientations) ||

          matchGradient(
            'repeating-radial-gradient',
            tokens.repeatingRadialGradient,
            matchListRadialOrientations);
  }

  function matchGradient(gradientType, pattern, orientationMatcher) {
    return matchCall(pattern, function(captures) {

      var orientation = orientationMatcher();
      if (orientation) {
        if (!scan(tokens.comma)) {
          error('Missing comma before color stops');
        }
      }

      return {
        type: gradientType,
        orientation: orientation,
        colorStops: matchListing(matchColorStop)
      };
    });
  }

  function matchCall(pattern, callback) {
    var captures = scan(pattern);

    if (captures) {
      if (!scan(tokens.startCall)) {
        error('Missing (');
      }

      result = callback(captures);

      if (!scan(tokens.endCall)) {
        error('Missing )');
      }

      return result;
    }
  }

  function matchLinearOrientation() {
    return matchSideOrCorner() ||
      matchAngle();
  }

  function matchSideOrCorner() {
    return match('directional', tokens.sideOrCorner, 1);
  }

  function matchAngle() {
    return match('angular', tokens.angleValue, 1);
  }

  function matchListRadialOrientations() {
    var radialOrientations,
        radialOrientation = matchRadialOrientation(),
        lookaheadCache;

    if (radialOrientation) {
      radialOrientations = [];
      radialOrientations.push(radialOrientation);

      lookaheadCache = input;
      if (scan(tokens.comma)) {
        radialOrientation = matchRadialOrientation();
        if (radialOrientation) {
          radialOrientations.push(radialOrientation);
        } else {
          input = lookaheadCache;
        }
      }
    }

    return radialOrientations;
  }

  function matchRadialOrientation() {
    var radialType = matchCircle() ||
      matchEllipse();

    if (radialType) {
      radialType.at = matchAtPosition();
    } else {
      var extent = matchExtentKeyword();
      if (extent) {
        radialType = extent;
        var positionAt = matchAtPosition();
        if (positionAt) {
          radialType.at = positionAt;
        }
      } else {
        var defaultPosition = matchPositioning();
        if (defaultPosition) {
          radialType = {
            type: 'default-radial',
            at: defaultPosition
          };
        }
      }
    }

    return radialType;
  }

  function matchCircle() {
    var circle = match('shape', /^(circle)/i, 0);

    if (circle) {
      circle.style = matchLength() || matchExtentKeyword();
    }

    return circle;
  }

  function matchEllipse() {
    var ellipse = match('shape', /^(ellipse)/i, 0);

    if (ellipse) {
      ellipse.style =  matchDistance() || matchExtentKeyword();
    }

    return ellipse;
  }

  function matchExtentKeyword() {
    return match('extent-keyword', tokens.extentKeywords, 1);
  }

  function matchAtPosition() {
    if (match('position', /^at/, 0)) {
      var positioning = matchPositioning();

      if (!positioning) {
        error('Missing positioning value');
      }

      return positioning;
    }
  }

  function matchPositioning() {
    var location = matchCoordinates();

    if (location.x || location.y) {
      return {
        type: 'position',
        value: location
      };
    }
  }

  function matchCoordinates() {
    return {
      x: matchDistance(),
      y: matchDistance()
    };
  }

  function matchListing(matcher) {
    var captures = matcher(),
      result = [];

    if (captures) {
      result.push(captures);
      while (scan(tokens.comma)) {
        captures = matcher();
        if (captures) {
          result.push(captures);
        } else {
          error('One extra comma');
        }
      }
    }

    return result;
  }

  function matchColorStop() {
    var color = matchColor();

    if (!color) {
      error('Expected color definition');
    }

    color.length = matchDistance();
    return color;
  }

  function matchColor() {
    return matchHexColor() ||
      matchRGBAColor() ||
      matchRGBColor() ||
      matchLiteralColor();
  }

  function matchLiteralColor() {
    return match('literal', tokens.literalColor, 0);
  }

  function matchHexColor() {
    return match('hex', tokens.hexColor, 1);
  }

  function matchRGBColor() {
    return matchCall(tokens.rgbColor, function() {
      return  {
        type: 'rgb',
        value: matchListing(matchNumber)
      };
    });
  }

  function matchRGBAColor() {
    return matchCall(tokens.rgbaColor, function() {
      return  {
        type: 'rgba',
        value: matchListing(matchNumber)
      };
    });
  }

  function matchNumber() {
    return scan(tokens.number)[1];
  }

  function matchDistance() {
    return match('%', tokens.percentageValue, 1) ||
      matchPositionKeyword() ||
      matchLength();
  }

  function matchPositionKeyword() {
    return match('position-keyword', tokens.positionKeywords, 1);
  }

  function matchLength() {
    return match('px', tokens.pixelValue, 1) ||
      match('em', tokens.emValue, 1);
  }

  function match(type, pattern, captureIndex) {
    var captures = scan(pattern);
    if (captures) {
      return {
        type: type,
        value: captures[captureIndex]
      };
    }
  }

  function scan(regexp) {
    var captures,
        blankCaptures;

    blankCaptures = /^[\n\r\t\s]+/.exec(input);
    if (blankCaptures) {
        consume(blankCaptures[0].length);
    }

    captures = regexp.exec(input);
    if (captures) {
        consume(captures[0].length);
    }

    return captures;
  }

  function consume(size) {
    input = input.substr(size);
  }

  return function(code) {
    input = code.toString();
    return getAST();
  };
})();