Repository: fabricjs/fabric.js
Pull request #9915 (2024-06-07 10:17:49)

Add support for decimals in hsla colors and some tests for strings->colors
I have added support for numerals with decimal places when parsing hsla colors.

These can appear in svg files especially, they are otherwise rendered black.

I also added some jest tests to support this.

Thanks !


codesandbox[bot]: #### Review or Edit in CodeSandbox

Open the branch in <a href="https://codesandbox.io/p/github/williamforster/fabric.js/color-tests?mode=review&utm_source=gh_app">Web Editor</a> &bull; <a href="https://codesandbox.io/p/vscode?owner=williamforster&repo=fabric.js&branch=color-tests&utm_source=gh_app">VS Code</a> &bull; <a href="https://codesandbox.io/p/vscode?owner=williamforster&repo=fabric.js&branch=color-tests&insiders=true&utm_source=gh_app">Insiders</a><br>
Open <a href="https://codesandbox.io/p/devtool/preview/williamforster/fabric.js/color-tests?task=preview&port=51423&redirect=true&utm_source=gh_app">Preview</a>

<!-- open-in-codesandbox:complete -->


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4646f41b7..70c559d0e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
 
 ## [next]
 
+- feat(Color): add support for decimals and different angle types in HSL color parsing [#9915](https://github.com/fabricjs/fabric.js/pull/9915)
 - fix(Controls): add support for numeric origins to changeWidth [#9909](https://github.com/fabricjs/fabric.js/pull/9909)
 - fix(\_renderControls): fixed render order so group controls are rendered over child objects [#9914](https://github.com/fabricjs/fabric.js/pull/9914)
 - fix(filters): RemoveColor has missing getFragmentSource method ( typo ) [#9911](https://github.com/fabricjs/fabric.js/pull/9911)
diff --git a/src/color/Color.ts b/src/color/Color.ts
index 1c5616dd1..20113c036 100644
--- a/src/color/Color.ts
+++ b/src/color/Color.ts
@@ -1,3 +1,4 @@
+import { radiansToDegrees } from '../util/misc/radiansDegreesConversion';
 import { ColorNameMap } from './color_map';
 import { reHSLa, reHex, reRGBa } from './constants';
 import type { TRGBAColorSource, TColorArg } from './typedefs';
@@ -258,8 +259,9 @@ export class Color {
     if (!match) {
       return;
     }
+    const match1degrees = Color.parseAngletoDegrees(match[1]);
 
-    const h = (((parseFloat(match[1]) % 360) + 360) % 360) / 360,
+    const h = (((match1degrees % 360) + 360) % 360) / 360,
       s = parseFloat(match[2]) / 100,
       l = parseFloat(match[3]) / 100;
     let r: number, g: number, b: number;
@@ -317,4 +319,28 @@ export class Color {
       return [r, g, b, a / 255];
     }
   }
+
+  /**
+   * Converts a string that could be any angle notation (50deg, 0.5turn, 2rad)
+   * into degrees without the 'deg' suffix
+   * @static
+   * @memberOf Color
+   * @param {String} value ex: 0deg, 0.5turn, 2rad
+   * @return {Number} number in degrees or NaN if inputs are invalid
+   */
+  static parseAngletoDegrees(value: string): number {
+    const lowercase = value.toLowerCase();
+    const numeric = parseFloat(lowercase);
+
+    if (lowercase.includes('rad')) {
+      return radiansToDegrees(numeric);
+    }
+
+    if (lowercase.includes('turn')) {
+      return numeric * 360;
+    }
+
+    // Value is probably just a number already in degrees eg '50'
+    return numeric;
+  }
 }
diff --git a/src/color/constants.ts b/src/color/constants.ts
index ce653b80c..53249ba3e 100644
--- a/src/color/constants.ts
+++ b/src/color/constants.ts
@@ -75,15 +75,22 @@ export const reRGBa = () =>
  *
  * /^hsla?\(         // Matches the beginning of the string and the opening parenthesis of "hsl" or "hsla"
  * \s*               // Matches any whitespace characters (space, tab, etc.) zero or more times
- * (\d{1,3})         // Hue: Matches one to three digits and captures it in a group
+ * (\d{0,3}          // Hue: 0 to three digits - start capture in a group
+ * (?:\.\d+)?        // Hue: Optional (non capture group) decimal with one or more digits.
+ * (?:deg|turn|rad)? // Hue: Optionally include suffix deg or turn or rad
+ * )                 // Hue: End capture group
  * \s*               // Matches any whitespace characters zero or more times
  * [\s|,]            // Matches a space, tab or comma
  * \s*               // Matches any whitespace characters zero or more times
- * (\d{1,3}%)        // Saturation: Matches one to three digits followed by a percentage sign and captures it in a group
+ * (\d{0,3}          // Saturation: 0 to three digits - start capture in a group
+ * (?:\.\d+)?        // Saturation: Optional decimal with one or more digits in a non-capturing group
+ * %?)               // Saturation: match optional % character and end capture group
  * \s*               // Matches any whitespace characters zero or more times
  * [\s|,]            // Matches a space, tab or comma
  * \s*               // Matches any whitespace characters zero or more times
- * (\d{1,3}%)        // Lightness: Matches one to three digits followed by a percentage sign and captures it in a group
+ * (\d{0,3}          // Lightness: 0 to three digits - start capture in a group
+ * (?:\.\d+)?        // Lightness: Optional decimal with one or more digits in a non-capturing group
+ * %?)                // Lightness: match % character and end capture group
  * \s*               // Matches any whitespace characters zero or more times
  * (?:               // Alpha: Begins a non-capturing group for the alpha value
  *   \s*             // Matches any whitespace characters zero or more times
@@ -99,7 +106,7 @@ export const reRGBa = () =>
  * So the spec does not allow `hsl(30 , 45%  35, 49%)` but this will work anyways for us.
  */
 export const reHSLa = () =>
-  /^hsla?\(\s*([+-]?\d{1,3})\s*[\s|,]\s*(\d{1,3}%)\s*[\s|,]\s*(\d{1,3}%)\s*(?:\s*[,/]\s*(\d*(?:\.\d+)?%?)\s*)?\)$/i;
+  /^hsla?\(\s*([+-]?\d{0,3}(?:\.\d+)?(?:deg|turn|rad)?)\s*[\s|,]\s*(\d{0,3}(?:\.\d+)?%?)\s*[\s|,]\s*(\d{0,3}(?:\.\d+)?%?)\s*(?:\s*[,/]\s*(\d*(?:\.\d+)?%?)\s*)?\)$/i;
 
 /**
  * Regex matching color in HEX format (ex: #FF5544CC, #FF5555, 010155, aff)


Test patch:
diff --git a/src/color/color.test.ts b/src/color/color.test.ts
new file mode 100644
index 000000000..6e45d0f9a
--- /dev/null
+++ b/src/color/color.test.ts
@@ -0,0 +1,52 @@
+import { Color } from './Color';
+
+describe('Color regex and conversion tests', () => {
+  it('Converts a hsl color to rgba', () => {
+    const color1 = new Color('hsl(120, 100%, 50%)');
+    expect(color1.getSource().toString()).toBe([0, 255, 0, 1].toString());
+  });
+
+  it('Converts a hsl color with minuses and decimals to rgba', () => {
+    const color1 = new Color('hsl(-20, 50.5%, 50%)');
+    expect(color1.getSource().toString()).toBe([192, 63, 106, 1].toString());
+  });
+
+  it('Converts a hsla color with minuses and decimals to rgba', () => {
+    const color1 = new Color('hsla(-20, 50.5%, 50%, 0.4)');
+    expect(color1.getSource().toString()).toBe([192, 63, 106, 0.4].toString());
+  });
+
+  it('Converts a hsla color with minuses and decimals and a % alpha to rgba', () => {
+    const color1 = new Color('hsla(-20, 50.5%, 50%, 25%)');
+    expect(color1.getSource().toString()).toBe([192, 63, 106, 0.25].toString());
+  });
+
+  it('Converts a hsla color with angle deg to rgba', () => {
+    const color1 = new Color('hsl(120deg,100%,50%)');
+    expect(color1.getSource().toString()).toBe([0, 255, 0, 1].toString());
+  });
+
+  it('Converts a hsla color with angle rad to rgba', () => {
+    const color1 = new Color('hsl(2.0rad, 60%, 60%)');
+    expect(color1.getSource().toString()).toBe([103, 214, 92, 1].toString());
+  });
+
+  it('Converts a hsla color with angle turn to rgba', () => {
+    const color1 = new Color('hsl(0.5turn , 100%, 50%)');
+    expect(color1.getSource().toString()).toBe([0, 255, 255, 1].toString());
+  });
+
+  it('Creates a color from rgba with decimals', () => {
+    const color1 = new Color('rgba(120.1, 60.2, 30.3, 0.5)');
+    expect(color1.getSource().toString()).toBe(
+      [120.1, 60.2, 30.3, 0.5].toString()
+    );
+  });
+
+  it('Creates a color from rgb with decimals', () => {
+    const color1 = new Color('rgb(120.1, 60.2, 30.3)');
+    expect(color1.getSource().toString()).toBe(
+      [120.1, 60.2, 30.3, 1].toString()
+    );
+  });
+});