From b580aa17339eecc6532ecc054c0237b8dbfc3572 Mon Sep 17 00:00:00 2001
From: Vlad Shilov <omgovich@ya.ru>
Date: Sun, 18 Jul 2021 14:54:57 +0300
Subject: [PATCH] Parse CMYK strings, update specs

---
 CHANGELOG.md                  | 12 +++--
 README.md                     | 27 ++++++++--
 package.json                  | 12 ++++-
 src/colorModels/cmyk.ts       | 92 +++++++++++++++--------------------
 src/colorModels/cmykString.ts | 32 ++++++++++++
 src/plugins/cmyk.ts           |  5 +-
 tests/plugins.test.ts         |  8 ++-
 7 files changed, 124 insertions(+), 64 deletions(-)
 create mode 100644 src/colorModels/cmykString.ts

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 455d760..c6fc406 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+### 2.2.0
+
+- New plugin: CMYK color space ❤️ [@EricRovell](https://github.com/EricRovell)
+
 ### 2.1.0
 
 - Add new `hue` and `rotate` methods
@@ -51,8 +55,8 @@
 
 ### 1.2.1
 
-- Fix: Do not treat 7-digit hex as a valid color ❤️ @subzey
-- Parser update: Turn NaN input values into valid numbers ❤️ @subzey
+- Fix: Do not treat 7-digit hex as a valid color ❤️ [@subzey](https://github.com/subzey)
+- Parser update: Turn NaN input values into valid numbers ❤️ [@subzey](https://github.com/subzey)
 
 ### 1.2.0
 
@@ -72,7 +76,7 @@
 
 ### 0.10.2
 
-- Sort named colors dictionary for better compression ❤️ @subzey
+- Sort named colors dictionary for better compression ❤️ [@subzey](https://github.com/subzey)
 
 ### 0.10.1
 
@@ -109,7 +113,7 @@
 
 ### 0.6.2
 
-- 20% speed improvement ❤️ @jeetiss
+- 20% speed improvement ❤️ [@jeetiss](https://github.com/jeetiss)
 
 ### 0.6.1
 
diff --git a/README.md b/README.md
index e429bdd..0adfa24 100644
--- a/README.md
+++ b/README.md
@@ -245,6 +245,25 @@ colord("#555aaa").toCmyk(); // { c: 50, m: 47, y: 0, k: 33, a: 1 }
 
 </details>
 
+<details>
+  <summary><b><code>.toCmykString()</code></b> (<b>cmyk</b> plugin)</summary>
+
+Converts a color to color space.
+
+Converts a color to [CMYK](https://en.wikipedia.org/wiki/CMYK_color_model) color space and expresses it through the [functional notation](https://www.w3.org/TR/css-color-4/#device-cmyk)
+
+```js
+import { colord, extend } from "colord";
+import cmykPlugin from "colord/plugins/cmyk";
+
+extend([cmykPlugin]);
+
+colord("#99ffff").toCmykString(); // "device-cmyk(40% 0% 0% 0%)"
+colord("#00336680").toCmykString(); // "device-cmyk(100% 50% 0% 60% / 0.5)"
+```
+
+</details>
+
 <details>
   <summary><b><code>.toHwb()</code></b> (<b>hwb</b> plugin)</summary>
 
@@ -655,9 +674,9 @@ colord("#e60000").isReadable("#ffff47", { level: "AAA", size: "large" }); // tru
 </details>
 
 <details>
-  <summary><b><code>cmyk</code> (CMYK color space)</b> <i>1.38 KB</i></summary>
+  <summary><b><code>cmyk</code> (CMYK color space)</b> <i>0.6 KB</i></summary>
 
-Adds support of [CMYK](https://www.sttmedia.com/colormodel-cmyk) color model. The conversion logic is ported from [CSS Color Module Level 4 Specification](https://www.w3.org/TR/css-color-4/#color-conversion-code).
+Adds support of [CMYK](https://www.sttmedia.com/colormodel-cmyk) color model.
 
 ```js
 import { colord, extend } from "colord";
@@ -666,7 +685,9 @@ import cmykPlugin from "colord/plugins/cmyk";
 extend([cmykPlugin]);
 
 colord("#ffffff").toCmyk(); // { c: 0, m: 0, y: 0, k: 0, a: 1 }
+colord("#999966").toCmykString(); // "device-cmyk(0% 0% 33% 40%)"
 colord({ c: 0, m: 0, y: 0, k: 100, a: 1 }).toHex(); // "#000000"
+colord("device-cmyk(0% 61% 72% 0% / 50%)").toHex(); // "#ff634780"
 ```
 
 </details>
@@ -833,4 +854,4 @@ const bar: RgbColor = { r: 0, g: 0, v: 0 }; // ERROR
 - [x] [LAB](https://www.w3.org/TR/css-color-4/#resolving-lab-lch-values) color space (via plugin)
 - [x] [LCH](https://lea.verou.me/2020/04/lch-colors-in-css-what-why-and-how/) color space (via plugin)
 - [x] Mix colors (via plugin)
-- [ ] CMYK color space (via plugin)
+- [x] CMYK color space (via plugin)
diff --git a/package.json b/package.json
index db5ab4b..8712274 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "colord",
-  "version": "2.1.0",
+  "version": "2.2.0",
   "description": "👑 A tiny yet powerful tool for high-performance color manipulations and conversions",
   "keywords": [
     "color",
@@ -18,6 +18,7 @@
     "css",
     "color-names",
     "a11y",
+    "cmyk",
     "mix"
   ],
   "repository": "omgovich/colord",
@@ -37,6 +38,11 @@
       "require": "./plugins/a11y.js",
       "default": "./plugins/a11y.mjs"
     },
+    "./plugins/cmyk": {
+      "import": "./plugins/cmyk.mjs",
+      "require": "./plugins/cmyk.js",
+      "default": "./plugins/cmyk.mjs"
+    },
     "./plugins/hwb": {
       "import": "./plugins/hwb.mjs",
       "require": "./plugins/hwb.js",
@@ -140,6 +146,10 @@
       "path": "dist/plugins/a11y.mjs",
       "limit": "0.5 KB"
     },
+    {
+      "path": "dist/plugins/cmyk.mjs",
+      "limit": "1 KB"
+    },
     {
       "path": "dist/plugins/hwb.mjs",
       "limit": "1 KB"
diff --git a/src/colorModels/cmyk.ts b/src/colorModels/cmyk.ts
index 14caaea..5840c90 100644
--- a/src/colorModels/cmyk.ts
+++ b/src/colorModels/cmyk.ts
@@ -5,44 +5,35 @@ import { clamp, isPresent, round } from "../helpers";
 /**
  * Clamps the CMYK color object values.
  */
-export function clampCmyka({ c, m, y, k, a = 1 }: CmykaColor): CmykaColor {
-  return {
-    c: clamp(c, 0, 100),
-    m: clamp(m, 0, 100),
-    y: clamp(y, 0, 100),
-    k: clamp(k, 0, 100),
-    a: clamp(a),
-  };
-}
+export const clampCmyka = (cmyka: CmykaColor): CmykaColor => ({
+  c: clamp(cmyka.c, 0, 100),
+  m: clamp(cmyka.m, 0, 100),
+  y: clamp(cmyka.y, 0, 100),
+  k: clamp(cmyka.k, 0, 100),
+  a: clamp(cmyka.a),
+});
 
 /**
  * Rounds the CMYK color object values.
  */
-export function roundCmyka({ c, m, y, k, a = 1 }: CmykaColor): CmykaColor {
-  return {
-    c: round(c, 2),
-    m: round(m, 2),
-    y: round(y, 2),
-    k: round(k, 2),
-    a: round(a, ALPHA_PRECISION),
-  };
-}
+export const roundCmyka = (cmyka: CmykaColor): CmykaColor => ({
+  c: round(cmyka.c, 2),
+  m: round(cmyka.m, 2),
+  y: round(cmyka.y, 2),
+  k: round(cmyka.k, 2),
+  a: round(cmyka.a, ALPHA_PRECISION),
+});
 
 /**
  * Transforms the CMYK color object to RGB.
  * https://www.rapidtables.com/convert/color/cmyk-to-rgb.html
  */
-export function cmykaToRgba(color: CmykaColor): RgbaColor {
-  const c = color.c / 100;
-  const m = color.m / 100;
-  const y = color.y / 100;
-  const k = color.k / 100;
-  const a = color?.a ?? 1;
+export function cmykaToRgba(cmyka: CmykaColor): RgbaColor {
   return {
-    r: round(255 * (1 - c) * (1 - k)),
-    g: round(255 * (1 - m) * (1 - k)),
-    b: round(255 * (1 - y) * (1 - k)),
-    a: a ? round(a, ALPHA_PRECISION) : 1,
+    r: round(255 * (1 - cmyka.c / 100) * (1 - cmyka.k / 100)),
+    g: round(255 * (1 - cmyka.m / 100) * (1 - cmyka.k / 100)),
+    b: round(255 * (1 - cmyka.y / 100) * (1 - cmyka.k / 100)),
+    a: cmyka.a,
   };
 }
 
@@ -50,16 +41,18 @@ export function cmykaToRgba(color: CmykaColor): RgbaColor {
  * Convert RGB Color Model object to CMYK.
  * https://www.rapidtables.com/convert/color/rgb-to-cmyk.html
  */
-export function rgbaToCmyka(color: RgbaColor): CmykaColor {
-  const [r, g, b, a] = [color.r / 255, color.g / 255, color.b / 255, color.a ?? 1];
-  const k = 1 - Math.max(r, g, b);
-  const [c, m, y] = [(1 - r - k) / (1 - k), (1 - g - k) / (1 - k), (1 - b - k) / (1 - k)];
+export function rgbaToCmyka(rgba: RgbaColor): CmykaColor {
+  const k = 1 - Math.max(rgba.r / 255, rgba.g / 255, rgba.b / 255);
+  const c = (1 - rgba.r / 255 - k) / (1 - k);
+  const m = (1 - rgba.g / 255 - k) / (1 - k);
+  const y = (1 - rgba.b / 255 - k) / (1 - k);
+
   return {
-    c: Number.isNaN(c) ? 0 : round(c * 100),
-    m: Number.isNaN(m) ? 0 : round(m * 100),
-    y: Number.isNaN(y) ? 0 : round(y * 100),
+    c: isNaN(c) ? 0 : round(c * 100),
+    m: isNaN(m) ? 0 : round(m * 100),
+    y: isNaN(y) ? 0 : round(y * 100),
     k: round(k * 100),
-    a: round(a, 2),
+    a: rgba.a,
   };
 }
 
@@ -67,22 +60,15 @@ export function rgbaToCmyka(color: RgbaColor): CmykaColor {
  * Parses the CMYK color object into RGB.
  */
 export function parseCmyka({ c, m, y, k, a = 1 }: InputObject): RgbaColor | null {
-  if (isPresent(c) && isPresent(m) && isPresent(y) && isPresent(k)) {
-    const cmyk = clampCmyka({
-      c: Number(c),
-      m: Number(m),
-      y: Number(y),
-      k: Number(k),
-      a: Number(a),
-    });
-    return cmykaToRgba(cmyk);
-  }
-  return null;
-}
+  if (!isPresent(c) || !isPresent(m) || !isPresent(y) || !isPresent(k)) return null;
+
+  const cmyk = clampCmyka({
+    c: Number(c),
+    m: Number(m),
+    y: Number(y),
+    k: Number(k),
+    a: Number(a),
+  });
 
-export function cmykToCmykaString(rgb: RgbaColor): string {
-  const { c, m, y, k, a } = roundCmyka(rgbaToCmyka(rgb));
-  return a < 1
-    ? `device-cmyk(${c}% ${m}% ${y}% ${k}% / ${a})`
-    : `device-cmyk(${c}% ${m}% ${y}% ${k}%)`;
+  return cmykaToRgba(cmyk);
 }
diff --git a/src/colorModels/cmykString.ts b/src/colorModels/cmykString.ts
new file mode 100644
index 0000000..3fe48aa
--- /dev/null
+++ b/src/colorModels/cmykString.ts
@@ -0,0 +1,32 @@
+import { RgbaColor } from "../types";
+import { clampCmyka, cmykaToRgba, rgbaToCmyka, roundCmyka } from "./cmyk";
+
+const cmykMatcher = /^device-cmyk\(\s*([+-]?\d*\.?\d+)(%)?\s+([+-]?\d*\.?\d+)(%)?\s+([+-]?\d*\.?\d+)(%)?\s+([+-]?\d*\.?\d+)(%)?\s*(?:\/\s*([+-]?\d*\.?\d+)(%)?\s*)?\)$/i;
+
+/**
+ * Parses a valid CMYK CSS color function/string
+ * https://www.w3.org/TR/css-color-4/#device-cmyk
+ */
+export const parseCmykaString = (input: string): RgbaColor | null => {
+  const match = cmykMatcher.exec(input);
+
+  if (!match) return null;
+
+  const cmyka = clampCmyka({
+    c: Number(match[1]) * (match[2] ? 1 : 100),
+    m: Number(match[3]) * (match[4] ? 1 : 100),
+    y: Number(match[5]) * (match[6] ? 1 : 100),
+    k: Number(match[7]) * (match[8] ? 1 : 100),
+    a: match[9] === undefined ? 1 : Number(match[9]) / (match[10] ? 100 : 1),
+  });
+
+  return cmykaToRgba(cmyka);
+};
+
+export function rgbaToCmykaString(rgb: RgbaColor): string {
+  const { c, m, y, k, a } = roundCmyka(rgbaToCmyka(rgb));
+
+  return a < 1
+    ? `device-cmyk(${c}% ${m}% ${y}% ${k}% / ${a})`
+    : `device-cmyk(${c}% ${m}% ${y}% ${k}%)`;
+}
diff --git a/src/plugins/cmyk.ts b/src/plugins/cmyk.ts
index 6b4ea59..a3a0ce7 100644
--- a/src/plugins/cmyk.ts
+++ b/src/plugins/cmyk.ts
@@ -1,7 +1,7 @@
 import { CmykaColor } from "../types";
 import { Plugin } from "../extend";
 import { parseCmyka, roundCmyka, rgbaToCmyka } from "../colorModels/cmyk";
-import { cmykToCmykaString } from "../colorModels/cmyk";
+import { parseCmykaString, rgbaToCmykaString } from "../colorModels/cmykString";
 
 declare module "../colord" {
   interface Colord {
@@ -30,10 +30,11 @@ const cmykPlugin: Plugin = (ColordClass, parsers): void => {
   };
 
   ColordClass.prototype.toCmykString = function () {
-    return cmykToCmykaString(this.rgba);
+    return rgbaToCmykaString(this.rgba);
   };
 
   parsers.object.push([parseCmyka, "cmyk"]);
+  parsers.string.push([parseCmykaString, "cmyk"]);
 };
 
 export default cmykPlugin;
diff --git a/tests/plugins.test.ts b/tests/plugins.test.ts
index 102ce2a..0b2096b 100644
--- a/tests/plugins.test.ts
+++ b/tests/plugins.test.ts
@@ -63,6 +63,12 @@ describe("cmyk", () => {
     expect(colord({ c: 0, m: 0, y: 0, k: 0, a: 1 }).toHex()).toBe("#ffffff");
   });
 
+  it("Parses CMYK color string", () => {
+    expect(colord("device-cmyk(0% 0% 0% 100%)").toHex()).toBe("#000000");
+    expect(colord("device-cmyk(0% 61% 72% 0% / 50%)").toHex()).toBe("#ff634780");
+    expect(colord("device-cmyk(0 0.61 0.72 0 / 0.5)").toHex()).toBe("#ff634780");
+  });
+
   it("Converts a color to CMYK object", () => {
     // https://htmlcolors.com/color-converter
     expect(colord("#000000").toCmyk()).toMatchObject({ c: 0, m: 0, y: 0, k: 100, a: 1 });
@@ -74,7 +80,7 @@ describe("cmyk", () => {
   });
 
   it("Converts a color to CMYK string", () => {
-    https://en.wikipedia.org/wiki/CMYK_color_model
+    // https://en.wikipedia.org/wiki/CMYK_color_model
     expect(colord("#999966").toCmykString()).toBe("device-cmyk(0% 0% 33% 40%)");
     expect(colord("#99ffff").toCmykString()).toBe("device-cmyk(40% 0% 0% 0%)");
     expect(colord("#00336680").toCmykString()).toBe("device-cmyk(100% 50% 0% 60% / 0.5)");