From 1ea652c24ba9349904bf00911f4ae92c3d9cb4d2 Mon Sep 17 00:00:00 2001 From: Dan Burzo Date: Thu, 23 Feb 2023 08:25:30 +0200 Subject: [PATCH 1/7] Baseline css color parser --- benchmark/package-lock.json | 13 + benchmark/package.json | 3 +- benchmark/tests/rgb-parse-speed.js | 22 ++ docs/color-spaces.md | 10 +- src/hsl/definition.js | 3 +- src/hsl/parseHsl.js | 18 +- src/hsl/parseHslModern.js | 39 +++ src/map.js | 3 + src/modes.js | 11 +- src/parse.js | 383 ++++++++++++++++++++++++++--- src/rgb/definition.js | 10 +- src/rgb/parseRgb.js | 25 +- src/rgb/parseRgbModern.js | 49 ++++ test/color-syntax.test.js | 57 +---- test/parse.test.js | 4 +- 15 files changed, 524 insertions(+), 126 deletions(-) create mode 100644 src/hsl/parseHslModern.js create mode 100644 src/rgb/parseRgbModern.js diff --git a/benchmark/package-lock.json b/benchmark/package-lock.json index c50e6586..ae4a95e7 100644 --- a/benchmark/package-lock.json +++ b/benchmark/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "devDependencies": { "chroma-js": "^2.4.2", + "colorjs.io": "^0.4.3", "d3-color": "^3.1.0", "d3-interpolate": "^3.0.1", "tinycolor2": "^1.5.2" @@ -21,6 +22,12 @@ "integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A==", "dev": true }, + "node_modules/colorjs.io": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.4.3.tgz", + "integrity": "sha512-Jr6NiWFZCuSECl23Bhe4jvDldQsE0ErnWrdl3xIUFy+Bkp0l8r5qt/iZlNH47/xxGP5izcyC8InjoUoI4Po+Pg==", + "dev": true + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -56,6 +63,12 @@ "integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A==", "dev": true }, + "colorjs.io": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.4.3.tgz", + "integrity": "sha512-Jr6NiWFZCuSECl23Bhe4jvDldQsE0ErnWrdl3xIUFy+Bkp0l8r5qt/iZlNH47/xxGP5izcyC8InjoUoI4Po+Pg==", + "dev": true + }, "d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", diff --git a/benchmark/package.json b/benchmark/package.json index 3f0d73ae..e6d49120 100644 --- a/benchmark/package.json +++ b/benchmark/package.json @@ -8,6 +8,7 @@ "chroma-js": "^2.4.2", "d3-color": "^3.1.0", "d3-interpolate": "^3.0.1", - "tinycolor2": "^1.5.2" + "tinycolor2": "^1.5.2", + "colorjs.io": "^0.4.3" } } diff --git a/benchmark/tests/rgb-parse-speed.js b/benchmark/tests/rgb-parse-speed.js index 9d216430..d129be9b 100644 --- a/benchmark/tests/rgb-parse-speed.js +++ b/benchmark/tests/rgb-parse-speed.js @@ -1,6 +1,7 @@ import chroma from 'chroma-js'; import { color } from 'd3-color'; import tinycolor from 'tinycolor2'; +import { ColorSpace, sRGB, parse } from 'colorjs.io/fn'; import { rgb } from '../../src/index.js'; import benchmark from '../util/benchmark.js'; @@ -50,3 +51,24 @@ benchmark('culori: culori("rgb(r,g,b)")', () => { } } }); + +benchmark('culori: culori("rgb(r g b)")', () => { + for (var r = 0; r <= 255; r += increment) { + for (var g = 0; g <= 255; g += increment) { + for (var b = 0; b <= 255; b += increment) { + rgb(`rgb(${r} ${g} ${b})`); + } + } + } +}); + +ColorSpace.register(sRGB); +benchmark('colorjs.io: parse("rgb(r g b)")', () => { + for (var r = 0; r <= 255; r += increment) { + for (var g = 0; g <= 255; g += increment) { + for (var b = 0; b <= 255; b += increment) { + parse(`rgb(${r} ${g} ${b})`); + } + } + } +}); diff --git a/docs/color-spaces.md b/docs/color-spaces.md index e1574553..0ff41dbf 100644 --- a/docs/color-spaces.md +++ b/docs/color-spaces.md @@ -378,13 +378,15 @@ The XYB color model is part of [the JPEG XL Image Coding System](https://ds.jpeg #### `xyb` -The default XYB color space, defined in relationship to sRGB, with the default Chroma from Luma adjustment applied. +The default XYB color space, defined in relationship to sRGB. + +It has the default _Chroma from Luma_ adjustment applied (effectively Y is subtracted from B) so that colors with `{ x: 0, b: 0 }` coordinates are achromatic. | Channel | Range | Description | | ------- | ------------- | ----------- | -| `x` | `[-0.0154, 0.0281]`≈ | ? | -| `y` | `[0, 0.8453]`≈ | ? | -| `b` | `[ -0.2778, 0.3880 ]` ≈ | ? | +| `x` | `[-0.0154, 0.0281]`≈ | Cyan-red component | +| `y` | `[0, 0.8453]`≈ | Luma | +| `b` | `[ -0.2778, 0.3880 ]` ≈ | Blue-yellow component | ### Cubehelix diff --git a/src/hsl/definition.js b/src/hsl/definition.js index dce623dc..afa2e272 100644 --- a/src/hsl/definition.js +++ b/src/hsl/definition.js @@ -1,6 +1,7 @@ import convertHslToRgb from './convertHslToRgb.js'; import convertRgbToHsl from './convertRgbToHsl.js'; import parseHsl from './parseHsl.js'; +import parseHslModern from './parseHslModern.js'; import { fixupHueShorter } from '../fixup/hue.js'; import { fixupAlpha } from '../fixup/alpha.js'; import { interpolatorLinear } from '../interpolate/linear.js'; @@ -24,7 +25,7 @@ const definition = { h: [0, 360] }, - parse: [parseHsl], + parse: [parseHslModern, parseHsl], serialize: c => `hsl(${c.h || 0} ${c.s !== undefined ? c.s * 100 + '%' : 'none'} ${ c.l !== undefined ? c.l * 100 + '%' : 'none' diff --git a/src/hsl/parseHsl.js b/src/hsl/parseHsl.js index 155f131a..de43c087 100644 --- a/src/hsl/parseHsl.js +++ b/src/hsl/parseHsl.js @@ -1,28 +1,16 @@ import hueToDeg from '../util/hue.js'; -import { - hue, - per, - num_per, - hue_none, - per_none, - num_per_none, - c, - s -} from '../util/regex.js'; +import { hue, per, num_per, c } from '../util/regex.js'; /* - hsl() regular expressions. + hsl() regular expressions for legacy format Reference: https://drafts.csswg.org/css-color/#the-hsl-notation */ const hsl_old = new RegExp( `^hsla?\\(\\s*${hue}${c}${per}${c}${per}\\s*(?:,\\s*${num_per}\\s*)?\\)$` ); -const hsl_new = new RegExp( - `^hsla?\\(\\s*${hue_none}${s}${per_none}${s}${per_none}\\s*(?:\\/\\s*${num_per_none}\\s*)?\\)$` -); const parseHsl = color => { - let match = color.match(hsl_old) || color.match(hsl_new); + let match = color.match(hsl_old); if (!match) return; let res = { mode: 'hsl' }; diff --git a/src/hsl/parseHslModern.js b/src/hsl/parseHslModern.js new file mode 100644 index 00000000..a295d4f5 --- /dev/null +++ b/src/hsl/parseHslModern.js @@ -0,0 +1,39 @@ +import { Tokens } from '../parse.js'; + +function parseHslModern(color, parsed) { + if (!color.match(/^hsla?\(/)) { + return undefined; + } + if (!parsed) { + return undefined; + } + const res = { mode: 'hsl' }; + if (parsed[0] !== undefined) { + if (parsed[0][0] === Tokens.Percentage) { + return undefined; + } + res.h = typeof parsed[0] === 'number' ? parsed[0] : parsed[0][1]; + } + if (parsed[1] !== undefined) { + if (parsed[1][0] === Tokens.Hue) { + return undefined; + } + res.s = typeof parsed[1] === 'number' ? parsed[1] : parsed[1][1] / 100; + } + if (parsed[2] !== undefined) { + if (parsed[2][0] === Tokens.Hue) { + return undefined; + } + res.l = typeof parsed[1] === 'number' ? parsed[2] : parsed[2][1] / 100; + } + if (parsed[3] !== undefined) { + if (parsed[3][0] === Tokens.Hue) { + return undefined; + } + res.alpha = + typeof parsed[3] === 'number' ? parsed[3] : parsed[3][1] / 100; + } + return res; +} + +export default parseHslModern; diff --git a/src/map.js b/src/map.js index 0b8da4e0..fad08433 100644 --- a/src/map.js +++ b/src/map.js @@ -7,6 +7,9 @@ const mapper = (fn, mode = 'rgb', preserve_mode = false) => { let conv = mode ? converter(mode) : prepare; return color => { let conv_color = conv(color); + if (!conv_color) { + return undefined; + } let res = (channels || getMode(color.mode).channels).reduce( (res, ch) => { let v = fn(conv_color[ch], ch, conv_color, mode); diff --git a/src/modes.js b/src/modes.js index 3bb1dc60..85e44a61 100644 --- a/src/modes.js +++ b/src/modes.js @@ -5,6 +5,7 @@ const modes = {}; const parsers = []; const colorProfiles = {}; +const modernColorFunctions = {}; const identity = v => v; @@ -56,7 +57,12 @@ const useMode = definition => { if (typeof parser === 'function') { useParser(parser); } else if (typeof parser === 'string') { - colorProfiles[parser] = definition.mode; + const m = parser.match(/^(.+)\(\)$/); + if (m) { + modernColorFunctions[m[1]] = definition.mode; + } else { + colorProfiles[parser] = definition.mode; + } } }); @@ -86,5 +92,6 @@ export { removeParser, converters, parsers, - colorProfiles + colorProfiles, + modernColorFunctions }; diff --git a/src/parse.js b/src/parse.js index 29303897..3deab5fb 100644 --- a/src/parse.js +++ b/src/parse.js @@ -1,65 +1,386 @@ import { parsers, colorProfiles, getMode } from './modes.js'; -import { rx_num_per_none } from './util/regex.js'; -function parseColorSyntax(color) { - const m = color.match(/^color\(\s*([a-z0-9-]+)\s*(.*?)\s*\)$/); - if (!m) { +/* eslint-disable-next-line no-control-regex */ +const IdentStartCodePoint = /[^\x00-\x7F]|[a-zA-Z_]/; + +/* eslint-disable-next-line no-control-regex */ +const IdentCodePoint = /[^\x00-\x7F]|[-\w]/; + +export const Tokens = { + Comma: 'comma', + Delim: 'delim', + Dimension: 'dimension', + Function: 'function', + Ident: 'ident', + Number: 'number', + Whitespace: ' ', + Percentage: 'percentage', + ParenClose: ')', + None: 'none', + Solidus: '/', + Hue: 'hue' +}; + +/* + Consume an escape sequence. + TODO: handle newlines and hex digits +*/ +function esc(chars) { + let v = ''; + if (!chars.length) { + throw new Error( + 'Unexpected end of input, unterminated escape sequence' + ); + } else { + // Consume escaped character + v += chars.shift(); + } + return v; +} + +/* + 4.3.10. Check if three code points would start a number + https://drafts.csswg.org/css-syntax/#starts-with-a-number + */ +function is_num(chars) { + let ch = chars[0]; + let ch1 = chars[1]; + if (ch === '-' || ch === '+') { + return /\d/.test(ch1) || (ch1 === '.' && /\d/.test(chars[2])); + } + if (ch === '.') { + return /\d/.test(ch1); + } + return /\d/.test(ch); +} + +/* + 4.3.3. Consume a numeric token + https://drafts.csswg.org/css-syntax/#consume-numeric-token + */ + +const huenits = { + deg: 1, + rad: 180 / Math.PI, + grad: 9 / 10, + turn: 360 +}; + +function num(chars) { + let value = ''; + if (/[+-]/.test(chars[0])) { + value += chars.shift(); + } + value += digits(chars); + if (chars[0] === '.' && /\d/.test(chars[1])) { + value += chars.shift() + digits(chars); + } + if (/e/i.test(chars[0])) { + if (/[+-]/.test(chars[1]) && /\d/.test(chars[2])) { + value += chars.shift() + chars.shift() + digits(chars); + } else if (/\d/.test(chars[1])) { + value += chars.shift() + digits(chars); + } + } + if (is_ident(chars)) { + let id = ident(chars); + // if (id === 'deg' || id === 'rad' || id === 'turn' || id === 'grad') { + if (/deg|rad|turn|grad/.test(id)) { + return [Tokens.Hue, value * huenits[id]]; + } + return [Tokens.Dimension, +value, id]; + } + if (chars[0] === '%') { + chars.shift(); + return [Tokens.Percentage, +value]; + } + return +value; +} + +/* + Consume digits + */ +function digits(chars) { + let v = ''; + while (/\d/.test(chars[0])) { + v += chars.shift(); + } + return v; +} + +/* + Check if the stream starts with an identifier. + */ + +function is_ident(chars) { + if (!chars.length) { + return false; + } + let ch = chars[0]; + if (ch.match(IdentStartCodePoint)) { + return true; + } + if (ch === '-') { + if (chars.length < 2) { + return false; + } + let ch1 = chars[1]; + if (ch1.match(IdentStartCodePoint) || ch1 === '-') { + return true; + } + if (ch1 === '\\') { + return !!esc(chars); + } + return false; + } + if (ch === '\\') { + return !!esc(chars); + } + return false; +} + +/* + Consume an identifier. + */ +function ident(chars) { + let v = '', + ch; + while ( + chars.length && + (chars[0].match(IdentCodePoint) || chars[0] === '\\') + ) { + v += (ch = chars.shift()) === '\\' ? esc(chars) : ch; + } + return v; +} + +/* + Consume an ident-like token. + */ +function identlike(chars) { + let v = ident(chars); + // TODO: handle URLs? + if (chars[0] === '(') { + chars.shift(); + return [Tokens.Function, v]; + } + if (v === 'none') { + return Tokens.None; + } + return [Tokens.Ident, v]; +} + +export function tokenize(str = '') { + let chars = str.trim().split(''); + let tokens = []; + let ch; + + while (chars.length) { + ch = chars.shift(); + + /* + Consume whitespace without emitting it + */ + if (ch.match(/[\n\t ]/)) { + while (chars.length && chars[0].match(/[\n\t ]/)) { + chars.shift(); + } + continue; + } + + if (ch === ')') { + tokens.push(Tokens.ParenClose); + continue; + } + + if (ch === '+') { + if (is_num(chars)) { + chars.unshift(ch); + tokens.push(num(chars)); + } else { + tokens.push([Tokens.Delim, ch]); + } + continue; + } + + if (ch === ',') { + tokens.push(Tokens.Comma); + continue; + } + + if (ch === '-') { + if (is_num(chars)) { + chars.unshift(ch); + tokens.push(num(chars)); + } else if (is_ident(chars)) { + chars.unshift(ch); + tokens.push([Tokens.Ident, ident(chars)]); + } else { + tokens.push([Tokens.Delim, ch]); + } + continue; + } + + if (ch === '.') { + if (is_num(chars)) { + chars.unshift(ch); + tokens.push(num(chars)); + } else { + tokens.push([Tokens.Delim, ch]); + } + continue; + } + + if (ch === '/') { + tokens.push(Tokens.Solidus); + continue; + } + + if (ch === '\\') { + if (chars.length && chars[0] !== '\n') { + chars.unshift(ch); + tokens.push(identlike(chars)); + continue; + } + throw new Error('Invalid escape'); + } + + if (ch.match(/\d/)) { + chars.unshift(ch); + tokens.push(num(chars)); + continue; + } + + if (ch.match(IdentStartCodePoint)) { + chars.unshift(ch); + tokens.push(identlike(chars)); + continue; + } + + /* + Treat everything not already handled + as a delimiter. + */ + tokens.push([Tokens.Delim, ch]); + } + + return tokens; +} + +export function parseColorSyntax(tokens) { + let token = tokens.shift(); + if (!token || token[0] !== Tokens.Function || token[1] !== 'color') { + return undefined; + } + token = tokens.shift(); + + if (token[0] !== Tokens.Ident) { return undefined; } - const mode = colorProfiles[m[1]]; + const mode = colorProfiles[token[1]]; if (!mode) { return undefined; } const res = { mode }; - const [cmp_string, alpha] = m[2].split(/\s*\/\s*/); - let cm; - if (alpha !== undefined) { - cm = alpha.match(rx_num_per_none); - if (!cm) { - return undefined; + const modeDef = getMode(mode); + if (!modeDef) { + return undefined; + } + for (let i = 0, ch; i < modeDef.channels.length; i++) { + ch = modeDef.channels[i]; + token = tokens.shift(); + if (ch === 'alpha') { + if (token === Tokens.ParenClose) { + continue; + } + if (token !== Tokens.Solidus) { + return undefined; + } + token = tokens.shift(); + } + if (typeof token === 'number') { + res[ch] = token; + continue; + } + if (token === 'none') { + continue; } - if (cm[1] !== undefined) { - res.alpha = cm[1] / 100; - } else if (cm[2] !== undefined) { - res.alpha = +cm[2]; + if (token[0] === Tokens.Percentage) { + res[ch] = token[1] / 100; + continue; } + return undefined; } - const components = cmp_string.split(/\s+/); - let channels = getMode(mode).channels; - for (let i = 0, ch; i < channels.length; i++) { - ch = channels[i]; - if (ch === 'alpha') { + + return res; +} + +function consumeCoords(tokens, i, includeHue) { + const coords = []; + let token; + while (i < tokens.length) { + token = tokens[i++]; + if (token === 'none') { + coords.push(undefined); continue; } - if (i >= components.length || !components[i]) { - res[ch] = 0; + if ( + token === Tokens.Solidus || + typeof token === 'number' || + token[0] === Tokens.Percentage || + token[0] === Tokens.Hue + ) { + coords.push(token); continue; } - if (!(cm = components[i].match(rx_num_per_none))) { - return undefined; + if (token === Tokens.ParenClose) { + if (i < tokens.length) { + return undefined; + } + continue; } - if (cm[1] !== undefined) { - res[ch] = cm[1] / 100; - } else if (cm[2] !== undefined) { - res[ch] = +cm[2]; + return undefined; + } + if (coords.length === 5) { + if (coords[3] !== Tokens.Solidus) { + return undefined; } + coords.splice(3, 1); } - return res; + if ( + coords.length < 3 || + coords.length > 4 || + coords.some(c => c === Tokens.Solidus) + ) { + return undefined; + } + return coords; +} + +export function parseModernSyntax(tokens, includeHue) { + let i = 0; + let token = tokens[i++]; + if (!token || token[0] !== Tokens.Function) { + return undefined; + } + return consumeCoords(tokens, i, includeHue); } const parse = color => { if (typeof color !== 'string') { return undefined; } + const tokens = tokenize(color); + const parsed = parseModernSyntax(tokens, true); let result = undefined; let i = 0; let len = parsers.length; while (i < len) { - if ((result = parsers[i++](color)) !== undefined) { + if ((result = parsers[i++](color, parsed)) !== undefined) { return result; } } - return parseColorSyntax(color); + return parseColorSyntax(tokens); }; export default parse; diff --git a/src/rgb/definition.js b/src/rgb/definition.js index 57e124fe..d1974fe0 100644 --- a/src/rgb/definition.js +++ b/src/rgb/definition.js @@ -1,6 +1,7 @@ import parseNamed from './parseNamed.js'; import parseHex from './parseHex.js'; import parseRgb from './parseRgb.js'; +import parseRgbModern from './parseRgbModern.js'; import parseTransparent from './parseTransparent.js'; import { interpolatorLinear } from '../interpolate/linear.js'; import { fixupAlpha } from '../fixup/alpha.js'; @@ -12,7 +13,14 @@ import { fixupAlpha } from '../fixup/alpha.js'; const definition = { mode: 'rgb', channels: ['r', 'g', 'b', 'alpha'], - parse: [parseHex, parseRgb, parseNamed, parseTransparent, 'srgb'], + parse: [ + parseHex, + parseRgbModern, + parseRgb, + parseNamed, + parseTransparent, + 'srgb' + ], serialize: 'srgb', interpolate: { diff --git a/src/rgb/parseRgb.js b/src/rgb/parseRgb.js index 7b08db42..6ffb8e0b 100644 --- a/src/rgb/parseRgb.js +++ b/src/rgb/parseRgb.js @@ -1,16 +1,7 @@ -import { - num, - per, - num_per, - num_none, - per_none, - num_per_none, - c, - s -} from '../util/regex.js'; +import { num, per, num_per, c } from '../util/regex.js'; /* - rgb() regular expressions. + rgb() regular expressions for legacy format Reference: https://drafts.csswg.org/css-color/#rgb-functions */ const rgb_num_old = new RegExp( @@ -21,18 +12,10 @@ const rgb_per_old = new RegExp( `^rgba?\\(\\s*${per}${c}${per}${c}${per}\\s*(?:,\\s*${num_per}\\s*)?\\)$` ); -const rgb_num_new = new RegExp( - `^rgba?\\(\\s*${num_none}${s}${num_none}${s}${num_none}\\s*(?:\\/\\s*${num_per_none}\\s*)?\\)$` -); - -const rgb_per_new = new RegExp( - `^rgba?\\(\\s*${per_none}${s}${per_none}${s}${per_none}\\s*(?:\\/\\s*${num_per_none}\\s*)?\\)$` -); - const parseRgb = color => { let res = { mode: 'rgb' }; let match; - if ((match = color.match(rgb_num_old) || color.match(rgb_num_new))) { + if ((match = color.match(rgb_num_old))) { if (match[1] !== undefined) { res.r = match[1] / 255; } @@ -42,7 +25,7 @@ const parseRgb = color => { if (match[3] !== undefined) { res.b = match[3] / 255; } - } else if ((match = color.match(rgb_per_old) || color.match(rgb_per_new))) { + } else if ((match = color.match(rgb_per_old))) { if (match[1] !== undefined) { res.r = match[1] / 100; } diff --git a/src/rgb/parseRgbModern.js b/src/rgb/parseRgbModern.js new file mode 100644 index 00000000..1e0cd5c6 --- /dev/null +++ b/src/rgb/parseRgbModern.js @@ -0,0 +1,49 @@ +import { Tokens } from '../parse.js'; + +function parseRgbModern(color, parsed) { + if (!color.match(/^rgba?\(/)) { + return undefined; + } + if (!parsed) { + return undefined; + } + const res = { mode: 'rgb' }; + if (parsed[0] !== undefined) { + if (parsed[0][0] === Tokens.Hue) { + return undefined; + } + res.r = + typeof parsed[0] === 'number' + ? parsed[0] / 255 + : parsed[0][1] / 100; + } + if (parsed[1] !== undefined) { + if (parsed[1][0] === Tokens.Hue) { + return undefined; + } + res.g = + typeof parsed[1] === 'number' + ? parsed[1] / 255 + : parsed[1][1] / 100; + } + if (parsed[2] !== undefined) { + if (parsed[2][0] === Tokens.Hue) { + return undefined; + } + res.b = + typeof parsed[2] === 'number' + ? parsed[2] / 255 + : parsed[2][1] / 100; + } + if (parsed[3] !== undefined) { + if (parsed[3][0] === Tokens.Hue) { + return undefined; + } + res.alpha = + typeof parsed[3] === 'number' ? parsed[3] : parsed[3][1] / 100; + } + + return res; +} + +export default parseRgbModern; diff --git a/test/color-syntax.test.js b/test/color-syntax.test.js index c449d117..c245bea9 100644 --- a/test/color-syntax.test.js +++ b/test/color-syntax.test.js @@ -2,58 +2,19 @@ import tape from 'tape'; import { parse } from '../src/index.js'; tape('fewer values than channels', t => { - t.deepEqual(parse('color(srgb)'), { mode: 'rgb', r: 0, g: 0, b: 0 }); - t.deepEqual(parse('color(srgb )'), { mode: 'rgb', r: 0, g: 0, b: 0 }); + t.deepEqual(parse('color(srgb)'), undefined); + t.deepEqual(parse('color(srgb )'), undefined); t.deepEqual(parse('color(srgb/)'), undefined); - t.deepEqual(parse('color(srgb /0.5)'), { - mode: 'rgb', - r: 0, - g: 0, - b: 0, - alpha: 0.5 - }); - t.deepEqual(parse('color(srgb 0.25)'), { - mode: 'rgb', - r: 0.25, - g: 0, - b: 0 - }); - t.deepEqual(parse('color(srgb 0.25 50%)'), { - mode: 'rgb', - r: 0.25, - g: 0.5, - b: 0 - }); - t.deepEqual(parse('color( srgb 25% .5 / 0.2)'), { - mode: 'rgb', - r: 0.25, - g: 0.5, - b: 0, - alpha: 0.2 - }); + t.deepEqual(parse('color(srgb /0.5)'), undefined); + t.deepEqual(parse('color(srgb 0.25)'), undefined); + t.deepEqual(parse('color(srgb 0.25 50%)'), undefined); + t.deepEqual(parse('color( srgb 25% .5 / 0.2)'), undefined); t.end(); }); tape('more values than channels', t => { - t.deepEqual(parse('color(srgb 25% .5 75% 0.33 0.66)'), { - mode: 'rgb', - r: 0.25, - g: 0.5, - b: 0.75 - }); - t.deepEqual(parse('color(srgb 25% .5 75% 0.33 0.66 / 70% )'), { - mode: 'rgb', - r: 0.25, - g: 0.5, - b: 0.75, - alpha: 0.7 - }); - t.deepEqual(parse('color(srgb 25% .5 75% 0.33 / 0.7)'), { - mode: 'rgb', - r: 0.25, - g: 0.5, - b: 0.75, - alpha: 0.7 - }); + t.deepEqual(parse('color(srgb 25% .5 75% 0.33 0.66)'), undefined); + t.deepEqual(parse('color(srgb 25% .5 75% 0.33 0.66 / 70% )'), undefined); + t.deepEqual(parse('color(srgb 25% .5 75% 0.33 / 0.7)'), undefined); t.end(); }); diff --git a/test/parse.test.js b/test/parse.test.js index 60f1f255..4295b07f 100644 --- a/test/parse.test.js +++ b/test/parse.test.js @@ -199,7 +199,7 @@ tape('hsl', function (test) { test.deepEqual( parse('hsl(0 1 0.5)'), - undefined, + { h: 0, s: 1, l: 0.5, mode: 'hsl' }, 'hsl current (no percentage)' ); @@ -211,7 +211,7 @@ tape('hsl', function (test) { test.deepEqual( parse('hsl(0 1 0.5 / 0.5)'), - undefined, + { h: 0, s: 1, l: 0.5, mode: 'hsl', alpha: 0.5 }, 'hsla current (no percentage)' ); From 8f6cd4982ae6ea60f8f17ac479d620892030cd59 Mon Sep 17 00:00:00 2001 From: Dan Burzo Date: Thu, 23 Feb 2023 08:55:55 +0200 Subject: [PATCH 2/7] Parser improvements --- src/hsl/parseHslModern.js | 35 ++-- src/parse.js | 352 ++++++++++++++++++-------------------- src/rgb/parseRgbModern.js | 49 ++---- test/rgb.test.js | 24 ++- 4 files changed, 219 insertions(+), 241 deletions(-) diff --git a/src/hsl/parseHslModern.js b/src/hsl/parseHslModern.js index a295d4f5..5860f82c 100644 --- a/src/hsl/parseHslModern.js +++ b/src/hsl/parseHslModern.js @@ -1,38 +1,41 @@ import { Tokens } from '../parse.js'; function parseHslModern(color, parsed) { - if (!color.match(/^hsla?\(/)) { + if (!parsed) { return undefined; } - if (!parsed) { + if (parsed[0] !== 'hsl' && parsed[0] !== 'hsla') { return undefined; } const res = { mode: 'hsl' }; - if (parsed[0] !== undefined) { - if (parsed[0][0] === Tokens.Percentage) { + const [_, h, s, l, alpha] = parsed; + + if (h.type !== Tokens.None) { + if (h.type === Tokens.Percentage) { return undefined; } - res.h = typeof parsed[0] === 'number' ? parsed[0] : parsed[0][1]; + res.h = h.value; } - if (parsed[1] !== undefined) { - if (parsed[1][0] === Tokens.Hue) { + + if (s.type !== Tokens.None) { + if (s.type === Tokens.Hue) { return undefined; } - res.s = typeof parsed[1] === 'number' ? parsed[1] : parsed[1][1] / 100; + res.s = s.type === Tokens.Number ? s.value : s.value / 100; } - if (parsed[2] !== undefined) { - if (parsed[2][0] === Tokens.Hue) { + + if (l.type !== Tokens.None) { + if (l.type === Tokens.Hue) { return undefined; } - res.l = typeof parsed[1] === 'number' ? parsed[2] : parsed[2][1] / 100; + res.l = l.type === Tokens.Number ? l.value : l.value / 100; } - if (parsed[3] !== undefined) { - if (parsed[3][0] === Tokens.Hue) { - return undefined; - } + + if (alpha.type !== Tokens.None) { res.alpha = - typeof parsed[3] === 'number' ? parsed[3] : parsed[3][1] / 100; + alpha.type === Tokens.Number ? alpha.value : alpha.value / 100; } + return res; } diff --git a/src/parse.js b/src/parse.js index 3deab5fb..c041aa96 100644 --- a/src/parse.js +++ b/src/parse.js @@ -7,46 +7,27 @@ const IdentStartCodePoint = /[^\x00-\x7F]|[a-zA-Z_]/; const IdentCodePoint = /[^\x00-\x7F]|[-\w]/; export const Tokens = { - Comma: 'comma', - Delim: 'delim', - Dimension: 'dimension', Function: 'function', Ident: 'ident', Number: 'number', - Whitespace: ' ', Percentage: 'percentage', ParenClose: ')', None: 'none', - Solidus: '/', - Hue: 'hue' + Hue: 'hue', + Alpha: 'alpha' }; -/* - Consume an escape sequence. - TODO: handle newlines and hex digits -*/ -function esc(chars) { - let v = ''; - if (!chars.length) { - throw new Error( - 'Unexpected end of input, unterminated escape sequence' - ); - } else { - // Consume escaped character - v += chars.shift(); - } - return v; -} - /* 4.3.10. Check if three code points would start a number https://drafts.csswg.org/css-syntax/#starts-with-a-number */ function is_num(chars) { - let ch = chars[0]; - let ch1 = chars[1]; + let ch = chars[chars._i]; + let ch1 = chars[chars._i + 1]; if (ch === '-' || ch === '+') { - return /\d/.test(ch1) || (ch1 === '.' && /\d/.test(chars[2])); + return ( + /\d/.test(ch1) || (ch1 === '.' && /\d/.test(chars[chars._i + 2])) + ); } if (ch === '.') { return /\d/.test(ch1); @@ -54,6 +35,31 @@ function is_num(chars) { return /\d/.test(ch); } +/* + Check if the stream starts with an identifier. + */ + +function is_ident(chars) { + if (chars._i >= chars.length) { + return false; + } + let ch = chars[chars._i]; + if (ch.match(IdentStartCodePoint)) { + return true; + } + if (ch === '-') { + if (chars.length - chars._i < 2) { + return false; + } + let ch1 = chars[chars._i + 1]; + if (ch1.match(IdentStartCodePoint) || ch1 === '-') { + return true; + } + return false; + } + return false; +} + /* 4.3.3. Consume a numeric token https://drafts.csswg.org/css-syntax/#consume-numeric-token @@ -68,88 +74,55 @@ const huenits = { function num(chars) { let value = ''; - if (/[+-]/.test(chars[0])) { - value += chars.shift(); + if (/[+-]/.test(chars[chars._i])) { + value += chars[chars._i++]; } value += digits(chars); - if (chars[0] === '.' && /\d/.test(chars[1])) { - value += chars.shift() + digits(chars); + if (chars[chars._i] === '.' && /\d/.test(chars[chars._i + 1])) { + value += chars[chars._i++] + digits(chars); } - if (/e/i.test(chars[0])) { - if (/[+-]/.test(chars[1]) && /\d/.test(chars[2])) { - value += chars.shift() + chars.shift() + digits(chars); - } else if (/\d/.test(chars[1])) { - value += chars.shift() + digits(chars); + if (/e/i.test(chars[chars._i])) { + if ( + /[+-]/.test(chars[chars._i + 1]) && + /\d/.test(chars[chars._i + 2]) + ) { + value += chars[chars._i++] + chars[chars._i++] + digits(chars); + } else if (/\d/.test(chars[chars._i + 1])) { + value += chars[chars._i++] + digits(chars); } } if (is_ident(chars)) { let id = ident(chars); - // if (id === 'deg' || id === 'rad' || id === 'turn' || id === 'grad') { if (/deg|rad|turn|grad/.test(id)) { - return [Tokens.Hue, value * huenits[id]]; + return { type: Tokens.Hue, value: value * huenits[id] }; } - return [Tokens.Dimension, +value, id]; + return undefined; } - if (chars[0] === '%') { - chars.shift(); - return [Tokens.Percentage, +value]; + if (chars[chars._i] === '%') { + chars._i++; + return { type: Tokens.Percentage, value: +value }; } - return +value; + return { type: Tokens.Number, value: +value }; } /* - Consume digits + Consume digits. */ function digits(chars) { let v = ''; - while (/\d/.test(chars[0])) { - v += chars.shift(); + while (/\d/.test(chars[chars._i])) { + v += chars[chars._i++]; } return v; } -/* - Check if the stream starts with an identifier. - */ - -function is_ident(chars) { - if (!chars.length) { - return false; - } - let ch = chars[0]; - if (ch.match(IdentStartCodePoint)) { - return true; - } - if (ch === '-') { - if (chars.length < 2) { - return false; - } - let ch1 = chars[1]; - if (ch1.match(IdentStartCodePoint) || ch1 === '-') { - return true; - } - if (ch1 === '\\') { - return !!esc(chars); - } - return false; - } - if (ch === '\\') { - return !!esc(chars); - } - return false; -} - /* Consume an identifier. */ function ident(chars) { - let v = '', - ch; - while ( - chars.length && - (chars[0].match(IdentCodePoint) || chars[0] === '\\') - ) { - v += (ch = chars.shift()) === '\\' ? esc(chars) : ch; + let v = ''; + while (chars._i < chars.length && chars[chars._i].match(IdentCodePoint)) { + v += chars[chars._i++]; } return v; } @@ -159,125 +132,142 @@ function ident(chars) { */ function identlike(chars) { let v = ident(chars); - // TODO: handle URLs? - if (chars[0] === '(') { - chars.shift(); - return [Tokens.Function, v]; + if (chars[chars._i] === '(') { + chars._i++; + return { type: Tokens.Function, value: v }; } if (v === 'none') { - return Tokens.None; + return { type: Tokens.None, value: undefined }; } - return [Tokens.Ident, v]; + return { type: Tokens.Ident, value: v }; } export function tokenize(str = '') { let chars = str.trim().split(''); + chars._i = 0; let tokens = []; let ch; - while (chars.length) { - ch = chars.shift(); + while (chars._i < chars.length) { + ch = chars[chars._i++]; /* Consume whitespace without emitting it */ - if (ch.match(/[\n\t ]/)) { - while (chars.length && chars[0].match(/[\n\t ]/)) { - chars.shift(); + if (ch === '\n' || ch === '\t' || ch === ' ') { + while ( + chars._i < chars.length && + (chars[chars._i] === '\n' || + chars[chars._i] === '\t' || + chars[chars._i] === ' ') + ) { + chars._i++; } continue; } + if (ch === ',') { + return undefined; + } + if (ch === ')') { - tokens.push(Tokens.ParenClose); + tokens.push({ type: Tokens.ParenClose }); continue; } if (ch === '+') { if (is_num(chars)) { - chars.unshift(ch); + chars._i--; tokens.push(num(chars)); - } else { - tokens.push([Tokens.Delim, ch]); + continue; } - continue; - } - - if (ch === ',') { - tokens.push(Tokens.Comma); - continue; + return undefined; } if (ch === '-') { if (is_num(chars)) { - chars.unshift(ch); + chars._i--; tokens.push(num(chars)); + continue; } else if (is_ident(chars)) { - chars.unshift(ch); - tokens.push([Tokens.Ident, ident(chars)]); - } else { - tokens.push([Tokens.Delim, ch]); + chars._i--; + tokens.push({ type: Tokens.Ident, value: ident(chars) }); + continue; } - continue; + return undefined; } if (ch === '.') { if (is_num(chars)) { - chars.unshift(ch); + chars._i--; tokens.push(num(chars)); - } else { - tokens.push([Tokens.Delim, ch]); + continue; } - continue; + return undefined; } if (ch === '/') { - tokens.push(Tokens.Solidus); - continue; - } - - if (ch === '\\') { - if (chars.length && chars[0] !== '\n') { - chars.unshift(ch); - tokens.push(identlike(chars)); - continue; + while ( + chars._i < chars.length && + (chars[chars._i] === '\n' || + chars[chars._i] === '\t' || + chars[chars._i] === ' ') + ) { + chars._i++; + } + let alpha; + if (is_num(chars)) { + alpha = num(chars); + if (alpha.type !== Tokens.Hue) { + tokens.push({ type: Tokens.Alpha, value: alpha }); + continue; + } + } + if (is_ident(chars)) { + if (ident(chars) === 'none') { + tokens.push({ + type: Tokens.Alpha, + value: { type: Tokens.None, value: undefined } + }); + continue; + } } - throw new Error('Invalid escape'); + return undefined; } if (ch.match(/\d/)) { - chars.unshift(ch); + chars._i--; tokens.push(num(chars)); continue; } if (ch.match(IdentStartCodePoint)) { - chars.unshift(ch); + chars._i--; tokens.push(identlike(chars)); continue; } /* - Treat everything not already handled - as a delimiter. + Treat everything not already handled as an error. */ - tokens.push([Tokens.Delim, ch]); + return undefined; } return tokens; } export function parseColorSyntax(tokens) { - let token = tokens.shift(); - if (!token || token[0] !== Tokens.Function || token[1] !== 'color') { + tokens._i = 0; + let token = tokens[tokens._i++]; + if (!token || token.type !== Tokens.Function || token.value !== 'color') { return undefined; } - token = tokens.shift(); + token = tokens[tokens._i++]; - if (token[0] !== Tokens.Ident) { + if (token.type !== Tokens.Ident) { return undefined; } - const mode = colorProfiles[token[1]]; + const mode = colorProfiles[token.value]; if (!mode) { return undefined; } @@ -286,84 +276,74 @@ export function parseColorSyntax(tokens) { if (!modeDef) { return undefined; } - for (let i = 0, ch; i < modeDef.channels.length; i++) { - ch = modeDef.channels[i]; - token = tokens.shift(); - if (ch === 'alpha') { - if (token === Tokens.ParenClose) { - continue; - } - if (token !== Tokens.Solidus) { - return undefined; - } - token = tokens.shift(); - } - if (typeof token === 'number') { - res[ch] = token; - continue; - } - if (token === 'none') { - continue; - } - if (token[0] === Tokens.Percentage) { - res[ch] = token[1] / 100; - continue; - } + const coords = consumeCoords(tokens, false); + if (!coords) { return undefined; } + for (let ii = 0, c; ii < modeDef.channels.length; ii++) { + c = coords[ii]; + if (c.type !== Tokens.None) { + res[modeDef.channels[ii]] = + c.type === Tokens.Number ? c.value : c.value / 100; + } + } return res; } -function consumeCoords(tokens, i, includeHue) { +function consumeCoords(tokens, includeHue) { const coords = []; let token; - while (i < tokens.length) { - token = tokens[i++]; - if (token === 'none') { - coords.push(undefined); - continue; - } + while (tokens._i < tokens.length) { + token = tokens[tokens._i++]; if ( - token === Tokens.Solidus || - typeof token === 'number' || - token[0] === Tokens.Percentage || - token[0] === Tokens.Hue + token.type === Tokens.None || + token.type === Tokens.Number || + token.type === Tokens.Alpha || + token.type === Tokens.Percentage || + token.type === Tokens.Hue ) { coords.push(token); continue; } - if (token === Tokens.ParenClose) { - if (i < tokens.length) { + if (token.type === Tokens.ParenClose) { + if (tokens._i < tokens.length) { return undefined; } continue; } return undefined; } - if (coords.length === 5) { - if (coords[3] !== Tokens.Solidus) { + + if (coords.length < 3 || coords.length > 4) { + return undefined; + } + + if (coords.length === 4) { + if (coords[3].type !== Tokens.Alpha) { return undefined; } - coords.splice(3, 1); + coords[3] = coords[3].value; } - if ( - coords.length < 3 || - coords.length > 4 || - coords.some(c => c === Tokens.Solidus) - ) { - return undefined; + if (coords.length === 3) { + coords.push({ type: Tokens.None, value: undefined }); } - return coords; + + return coords.every(c => c.type !== Tokens.Alpha) ? coords : undefined; } export function parseModernSyntax(tokens, includeHue) { - let i = 0; - let token = tokens[i++]; - if (!token || token[0] !== Tokens.Function) { + tokens._i = 0; + let token = tokens[tokens._i++]; + if (!token || token.type !== Tokens.Function) { return undefined; } - return consumeCoords(tokens, i, includeHue); + let coords = consumeCoords(tokens, includeHue); + if (!coords) { + return undefined; + } + coords.unshift(token.value); + return coords; } const parse = color => { @@ -371,7 +351,7 @@ const parse = color => { return undefined; } const tokens = tokenize(color); - const parsed = parseModernSyntax(tokens, true); + const parsed = tokens ? parseModernSyntax(tokens, true) : undefined; let result = undefined; let i = 0; let len = parsers.length; @@ -380,7 +360,7 @@ const parse = color => { return result; } } - return parseColorSyntax(tokens); + return tokens ? parseColorSyntax(tokens) : undefined; }; export default parse; diff --git a/src/rgb/parseRgbModern.js b/src/rgb/parseRgbModern.js index 1e0cd5c6..427d61e9 100644 --- a/src/rgb/parseRgbModern.js +++ b/src/rgb/parseRgbModern.js @@ -1,46 +1,33 @@ import { Tokens } from '../parse.js'; function parseRgbModern(color, parsed) { - if (!color.match(/^rgba?\(/)) { + if (!parsed) { return undefined; } - if (!parsed) { + if (parsed[0] !== 'rgb' && parsed[0] !== 'rgba') { return undefined; } const res = { mode: 'rgb' }; - if (parsed[0] !== undefined) { - if (parsed[0][0] === Tokens.Hue) { - return undefined; - } - res.r = - typeof parsed[0] === 'number' - ? parsed[0] / 255 - : parsed[0][1] / 100; + const [_, r, g, b, alpha] = parsed; + if ( + r.type === Tokens.Hue || + g.type === Tokens.Hue || + b.type === Tokens.Hue + ) { + return undefined; + } + if (r.type !== Tokens.None) { + res.r = r.type === Tokens.Number ? r.value / 255 : r.value / 100; } - if (parsed[1] !== undefined) { - if (parsed[1][0] === Tokens.Hue) { - return undefined; - } - res.g = - typeof parsed[1] === 'number' - ? parsed[1] / 255 - : parsed[1][1] / 100; + if (g.type !== Tokens.None) { + res.g = g.type === Tokens.Number ? g.value / 255 : g.value / 100; } - if (parsed[2] !== undefined) { - if (parsed[2][0] === Tokens.Hue) { - return undefined; - } - res.b = - typeof parsed[2] === 'number' - ? parsed[2] / 255 - : parsed[2][1] / 100; + if (b.type !== Tokens.None) { + res.b = b.type === Tokens.Number ? b.value / 255 : b.value / 100; } - if (parsed[3] !== undefined) { - if (parsed[3][0] === Tokens.Hue) { - return undefined; - } + if (alpha.type !== Tokens.None) { res.alpha = - typeof parsed[3] === 'number' ? parsed[3] : parsed[3][1] / 100; + alpha.type === Tokens.Number ? alpha.value : alpha.value / 100; } return res; diff --git a/test/rgb.test.js b/test/rgb.test.js index 5b2d8c85..16b6ef12 100644 --- a/test/rgb.test.js +++ b/test/rgb.test.js @@ -54,13 +54,17 @@ tape('rgb(Object)', function (test) { }); tape('color(srgb)', t => { - t.deepEqual(rgb('color(srgb 1 0 0 / 0.25)'), { - r: 1, - g: 0, - b: 0, - alpha: 0.25, - mode: 'rgb' - }); + t.deepEqual( + rgb('color(srgb 1 0 0 / 0.25)'), + { + r: 1, + g: 0, + b: 0, + alpha: 0.25, + mode: 'rgb' + }, + 'color(srgb 1 0 0 / 0.25)' + ); t.deepEqual(rgb('color(srgb 0% 50% 0.5 / 25%)'), { r: 0, g: 0.5, @@ -72,6 +76,10 @@ tape('color(srgb)', t => { }); tape('formatCss', t => { - t.equal(formatCss(rgb('color(srgb 1 0 0.5/1)')), 'color(srgb 1 0 0.5)'); + t.equal( + formatCss(rgb('color(srgb 1 0 0.5/1)')), + 'color(srgb 1 0 0.5)', + 'color(srgb 1 0 0.5/1)' + ); t.end(); }); From 063f76b5b62c2ac30769a17bafe15257214d29c2 Mon Sep 17 00:00:00 2001 From: Dan Burzo Date: Thu, 23 Feb 2023 12:49:44 +0200 Subject: [PATCH 3/7] Switch over all parsing to new parser. --- src/hsl/definition.js | 4 +-- src/hsl/parseHsl.js | 53 ++++++++++++++++---------------- src/hsl/parseHslLegacy.js | 39 ++++++++++++++++++++++++ src/hsl/parseHslModern.js | 42 ------------------------- src/hwb/parseHwb.js | 55 ++++++++++++++++----------------- src/index-fn.js | 4 +++ src/index.js | 4 +++ src/lab/parseLab.js | 46 +++++++++++----------------- src/lch/parseLch.js | 55 ++++++++++++--------------------- src/oklab/definition.js | 10 ++++-- src/oklab/parseOklab.js | 28 +++++++++++++++++ src/oklch/definition.js | 8 +++-- src/oklch/parseOklch.js | 31 +++++++++++++++++++ src/parse.js | 64 ++++++++++++++++++--------------------- src/rgb/definition.js | 7 ++--- src/rgb/parseRgb.js | 63 ++++++++++++-------------------------- src/rgb/parseRgbLegacy.js | 51 +++++++++++++++++++++++++++++++ src/rgb/parseRgbModern.js | 36 ---------------------- test/api.test.js | 8 +++++ test/oklab.test.js | 13 +++++++- test/oklch.test.js | 13 +++++++- 21 files changed, 346 insertions(+), 288 deletions(-) create mode 100644 src/hsl/parseHslLegacy.js delete mode 100644 src/hsl/parseHslModern.js create mode 100644 src/oklab/parseOklab.js create mode 100644 src/oklch/parseOklch.js create mode 100644 src/rgb/parseRgbLegacy.js delete mode 100644 src/rgb/parseRgbModern.js diff --git a/src/hsl/definition.js b/src/hsl/definition.js index afa2e272..12412c35 100644 --- a/src/hsl/definition.js +++ b/src/hsl/definition.js @@ -1,7 +1,7 @@ import convertHslToRgb from './convertHslToRgb.js'; import convertRgbToHsl from './convertRgbToHsl.js'; +import parseHslLegacy from './parseHslLegacy.js'; import parseHsl from './parseHsl.js'; -import parseHslModern from './parseHslModern.js'; import { fixupHueShorter } from '../fixup/hue.js'; import { fixupAlpha } from '../fixup/alpha.js'; import { interpolatorLinear } from '../interpolate/linear.js'; @@ -25,7 +25,7 @@ const definition = { h: [0, 360] }, - parse: [parseHslModern, parseHsl], + parse: [parseHsl, parseHslLegacy], serialize: c => `hsl(${c.h || 0} ${c.s !== undefined ? c.s * 100 + '%' : 'none'} ${ c.l !== undefined ? c.l * 100 + '%' : 'none' diff --git a/src/hsl/parseHsl.js b/src/hsl/parseHsl.js index de43c087..bb6a564c 100644 --- a/src/hsl/parseHsl.js +++ b/src/hsl/parseHsl.js @@ -1,39 +1,38 @@ -import hueToDeg from '../util/hue.js'; -import { hue, per, num_per, c } from '../util/regex.js'; +import { Tok } from '../parse.js'; -/* - hsl() regular expressions for legacy format - Reference: https://drafts.csswg.org/css-color/#the-hsl-notation - */ -const hsl_old = new RegExp( - `^hsla?\\(\\s*${hue}${c}${per}${c}${per}\\s*(?:,\\s*${num_per}\\s*)?\\)$` -); - -const parseHsl = color => { - let match = color.match(hsl_old); - if (!match) return; - let res = { mode: 'hsl' }; +function parseHsl(color, parsed) { + if (!parsed || (parsed[0] !== 'hsl' && parsed[0] !== 'hsla')) { + return undefined; + } + const res = { mode: 'hsl' }; + const [, h, s, l, alpha] = parsed; - if (match[3] !== undefined) { - res.h = +match[3]; - } else if (match[1] !== undefined && match[2] !== undefined) { - res.h = hueToDeg(match[1], match[2]); + if (h.type !== Tok.None) { + if (h.type === Tok.Percentage) { + return undefined; + } + res.h = h.value; } - if (match[4] !== undefined) { - res.s = Math.min(Math.max(0, match[4] / 100), 1); + if (s.type !== Tok.None) { + if (s.type === Tok.Hue) { + return undefined; + } + res.s = s.type === Tok.Number ? s.value : s.value / 100; } - if (match[5] !== undefined) { - res.l = Math.min(Math.max(0, match[5] / 100), 1); + if (l.type !== Tok.None) { + if (l.type === Tok.Hue) { + return undefined; + } + res.l = l.type === Tok.Number ? l.value : l.value / 100; } - if (match[6] !== undefined) { - res.alpha = match[6] / 100; - } else if (match[7] !== undefined) { - res.alpha = +match[7]; + if (alpha.type !== Tok.None) { + res.alpha = alpha.type === Tok.Number ? alpha.value : alpha.value / 100; } + return res; -}; +} export default parseHsl; diff --git a/src/hsl/parseHslLegacy.js b/src/hsl/parseHslLegacy.js new file mode 100644 index 00000000..2f4693ea --- /dev/null +++ b/src/hsl/parseHslLegacy.js @@ -0,0 +1,39 @@ +import hueToDeg from '../util/hue.js'; +import { hue, per, num_per, c } from '../util/regex.js'; + +/* + hsl() regular expressions for legacy format + Reference: https://drafts.csswg.org/css-color/#the-hsl-notation + */ +const hsl_old = new RegExp( + `^hsla?\\(\\s*${hue}${c}${per}${c}${per}\\s*(?:,\\s*${num_per}\\s*)?\\)$` +); + +const parseHslLegacy = color => { + let match = color.match(hsl_old); + if (!match) return; + let res = { mode: 'hsl' }; + + if (match[3] !== undefined) { + res.h = +match[3]; + } else if (match[1] !== undefined && match[2] !== undefined) { + res.h = hueToDeg(match[1], match[2]); + } + + if (match[4] !== undefined) { + res.s = Math.min(Math.max(0, match[4] / 100), 1); + } + + if (match[5] !== undefined) { + res.l = Math.min(Math.max(0, match[5] / 100), 1); + } + + if (match[6] !== undefined) { + res.alpha = match[6] / 100; + } else if (match[7] !== undefined) { + res.alpha = +match[7]; + } + return res; +}; + +export default parseHslLegacy; diff --git a/src/hsl/parseHslModern.js b/src/hsl/parseHslModern.js deleted file mode 100644 index 5860f82c..00000000 --- a/src/hsl/parseHslModern.js +++ /dev/null @@ -1,42 +0,0 @@ -import { Tokens } from '../parse.js'; - -function parseHslModern(color, parsed) { - if (!parsed) { - return undefined; - } - if (parsed[0] !== 'hsl' && parsed[0] !== 'hsla') { - return undefined; - } - const res = { mode: 'hsl' }; - const [_, h, s, l, alpha] = parsed; - - if (h.type !== Tokens.None) { - if (h.type === Tokens.Percentage) { - return undefined; - } - res.h = h.value; - } - - if (s.type !== Tokens.None) { - if (s.type === Tokens.Hue) { - return undefined; - } - res.s = s.type === Tokens.Number ? s.value : s.value / 100; - } - - if (l.type !== Tokens.None) { - if (l.type === Tokens.Hue) { - return undefined; - } - res.l = l.type === Tokens.Number ? l.value : l.value / 100; - } - - if (alpha.type !== Tokens.None) { - res.alpha = - alpha.type === Tokens.Number ? alpha.value : alpha.value / 100; - } - - return res; -} - -export default parseHslModern; diff --git a/src/hwb/parseHwb.js b/src/hwb/parseHwb.js index 23e3dc3c..8e08ba87 100644 --- a/src/hwb/parseHwb.js +++ b/src/hwb/parseHwb.js @@ -1,41 +1,38 @@ -import { hue_none, per_none, num_per_none, s } from '../util/regex.js'; -import hueToDeg from '../util/hue.js'; +import { Tok } from '../parse.js'; -/* - hwb() regular expressions. - Reference: https://drafts.csswg.org/css-color/#the-hwb-notation - */ -const hwb = new RegExp( - `^hwb\\(\\s*${hue_none}${s}${per_none}${s}${per_none}\\s*(?:\\/\\s*${num_per_none}\\s*)?\\)$` -); - -const parseHwb = color => { - let match = color.match(hwb); - if (!match) { +function ParseHwb(color, parsed) { + if (!parsed || parsed[0] !== 'hwb') { return undefined; } + const res = { mode: 'hwb' }; + const [, h, w, b, alpha] = parsed; - let res = { mode: 'hwb' }; - - if (match[3] !== undefined) { - res.h = +match[3]; - } else if (match[1] !== undefined && match[2] !== undefined) { - res.h = hueToDeg(match[1], match[2]); + if (h.type !== Tok.None) { + if (h.type === Tok.Percentage) { + return undefined; + } + res.h = h.value; } - if (match[4] !== undefined) { - res.w = match[4] / 100; + if (w.type !== Tok.None) { + if (w.type === Tok.Hue) { + return undefined; + } + res.w = w.type === Tok.Number ? w.value : w.value / 100; } - if (match[5] !== undefined) { - res.b = match[5] / 100; + + if (b.type !== Tok.None) { + if (b.type === Tok.Hue) { + return undefined; + } + res.b = b.type === Tok.Number ? b.value : b.value / 100; } - if (match[6] !== undefined) { - res.alpha = match[6] / 100; - } else if (match[7] !== undefined) { - res.alpha = +match[7]; + if (alpha.type !== Tok.None) { + res.alpha = alpha.type === Tok.Number ? alpha.value : alpha.value / 100; } + return res; -}; +} -export default parseHwb; +export default ParseHwb; diff --git a/src/index-fn.js b/src/index-fn.js index 550e66a4..4b5b110e 100644 --- a/src/index-fn.js +++ b/src/index-fn.js @@ -150,6 +150,10 @@ export { default as parseNamed } from './rgb/parseNamed.js'; export { default as parseTransparent } from './rgb/parseTransparent.js'; export { default as parseHex } from './rgb/parseHex.js'; export { default as parseRgb } from './rgb/parseRgb.js'; +export { default as parseHslLegacy } from './hsl/parseHslLegacy.js'; +export { default as parseRgbLegacy } from './rgb/parseRgbLegacy.js'; +export { default as parseOklab } from './oklab/parseOklab.js'; +export { default as parseOklch } from './oklch/parseOklch.js'; export { default as convertA98ToXyz65 } from './a98/convertA98ToXyz65.js'; export { default as convertCubehelixToRgb } from './cubehelix/convertCubehelixToRgb.js'; diff --git a/src/index.js b/src/index.js index fba64c52..2927a043 100644 --- a/src/index.js +++ b/src/index.js @@ -151,6 +151,10 @@ export { default as parseNamed } from './rgb/parseNamed.js'; export { default as parseTransparent } from './rgb/parseTransparent.js'; export { default as parseHex } from './rgb/parseHex.js'; export { default as parseRgb } from './rgb/parseRgb.js'; +export { default as parseHslLegacy } from './hsl/parseHslLegacy.js'; +export { default as parseRgbLegacy } from './rgb/parseRgbLegacy.js'; +export { default as parseOklab } from './oklab/parseOklab.js'; +export { default as parseOklch } from './oklch/parseOklch.js'; export { default as convertA98ToXyz65 } from './a98/convertA98ToXyz65.js'; export { default as convertCubehelixToRgb } from './cubehelix/convertCubehelixToRgb.js'; diff --git a/src/lab/parseLab.js b/src/lab/parseLab.js index 7e0d9eeb..07f97859 100644 --- a/src/lab/parseLab.js +++ b/src/lab/parseLab.js @@ -1,40 +1,28 @@ -import { num_none, per_none, num_per_none, s } from '../util/regex.js'; +import { Tok } from '../parse.js'; -/* - lab() and lch() regular expressions. - Reference: https://drafts.csswg.org/css-color/#lab-colors - */ -const lab = new RegExp( - `^lab\\(\\s*${per_none}${s}${num_none}${s}${num_none}\\s*(?:\\/\\s*${num_per_none}\\s*)?\\)$` -); - -const parseLab = color => { - let match = color.match(lab); - if (!match) { +function parseLab(color, parsed) { + if (!parsed || parsed[0] !== 'lab') { return undefined; } - - let res = { mode: 'lab' }; - - if (match[1] !== undefined) { - res.l = +match[1]; + const res = { mode: 'lab' }; + const [, l, a, b, alpha] = parsed; + if (l.type === Tok.Hue || a.type === Tok.Hue || b.type === Tok.Hue) { + return undefined; } - - if (match[2] !== undefined) { - res.a = +match[2]; + if (l.type !== Tok.None) { + res.l = l.value; } - - if (match[3] !== undefined) { - res.b = +match[3]; + if (a.type !== Tok.None) { + res.a = a.type === Tok.Number ? a.value : a.value / 125; } - - if (match[4] !== undefined) { - res.alpha = match[4] / 100; - } else if (match[5] !== undefined) { - res.alpha = +match[5]; + if (b.type !== Tok.None) { + res.b = b.type === Tok.Number ? b.value : b.value / 125; + } + if (alpha.type !== Tok.None) { + res.alpha = alpha.type === Tok.Number ? alpha.value : alpha.value / 100; } return res; -}; +} export default parseLab; diff --git a/src/lch/parseLch.js b/src/lch/parseLch.js index 8a335d87..d5a6d997 100644 --- a/src/lch/parseLch.js +++ b/src/lch/parseLch.js @@ -1,46 +1,31 @@ -import hueToDeg from '../util/hue.js'; -import { - hue_none, - num_none, - per_none, - num_per_none, - s -} from '../util/regex.js'; +import { Tok } from '../parse.js'; -const lch = new RegExp( - `^lch\\(\\s*${per_none}${s}${num_none}${s}${hue_none}\\s*(?:\\/\\s*${num_per_none}\\s*)?\\)$` -); - -const parseLch = color => { - let match = color.match(lch); - - if (!match) { +function parseLch(color, parsed) { + if (!parsed || parsed[0] !== 'lch') { return undefined; } - - let res = { mode: 'lch' }; - - if (match[1] !== undefined) { - res.l = +match[1]; + const res = { mode: 'lch' }; + const [, l, c, h, alpha] = parsed; + if (l.type !== Tok.None) { + if (l.type === Tok.Hue) { + return undefined; + } + res.l = l.value; } - - if (match[2] !== undefined) { - res.c = Math.max(0, +match[2]); + if (c.type !== Tok.None) { + res.c = Math.max(0, c.type === Tok.Number ? c.value : c.value / 150); } - - if (match[5] !== undefined) { - res.h = +match[5]; - } else if (match[3] !== undefined && match[4] !== undefined) { - res.h = hueToDeg(match[3], match[4]); + if (h.type !== Tok.None) { + if (h.type === Tok.Percentage) { + return undefined; + } + res.h = h.value; } - - if (match[6] !== undefined) { - res.alpha = match[6] / 100; - } else if (match[7] !== undefined) { - res.alpha = +match[7]; + if (alpha.type !== Tok.None) { + res.alpha = alpha.type === Tok.Number ? alpha.value : alpha.value / 100; } return res; -}; +} export default parseLch; diff --git a/src/oklab/definition.js b/src/oklab/definition.js index 8abd739f..7891d78f 100644 --- a/src/oklab/definition.js +++ b/src/oklab/definition.js @@ -2,6 +2,7 @@ import convertOklabToLrgb from './convertOklabToLrgb.js'; import convertLrgbToOklab from './convertLrgbToOklab.js'; import convertRgbToOklab from './convertRgbToOklab.js'; import convertOklabToRgb from './convertOklabToRgb.js'; +import parseOklab from './parseOklab.js'; import lab from '../lab/definition.js'; @@ -30,8 +31,13 @@ const definition = { b: [-0.311, 0.198] }, - parse: ['--oklab'], - serialize: '--oklab' + parse: [parseOklab, '--oklab'], + serialize: c => + `oklab(${c.l !== undefined ? c.l : 'none'} ${ + c.a !== undefined ? c.a : 'none' + } ${c.b !== undefined ? c.b : 'none'}${ + c.alpha < 1 ? ` / ${c.alpha}` : '' + })` }; export default definition; diff --git a/src/oklab/parseOklab.js b/src/oklab/parseOklab.js new file mode 100644 index 00000000..345a0064 --- /dev/null +++ b/src/oklab/parseOklab.js @@ -0,0 +1,28 @@ +import { Tok } from '../parse.js'; + +function parseOklab(color, parsed) { + if (!parsed || parsed[0] !== 'oklab') { + return undefined; + } + const res = { mode: 'oklab' }; + const [, l, a, b, alpha] = parsed; + if (l.type === Tok.Hue || a.type === Tok.Hue || b.type === Tok.Hue) { + return undefined; + } + if (l.type !== Tok.None) { + res.l = l.value; + } + if (a.type !== Tok.None) { + res.a = a.type === Tok.Number ? a.value : a.value / 0.4; + } + if (b.type !== Tok.None) { + res.b = b.type === Tok.Number ? b.value : b.value / 0.4; + } + if (alpha.type !== Tok.None) { + res.alpha = alpha.type === Tok.Number ? alpha.value : alpha.value / 100; + } + + return res; +} + +export default parseOklab; diff --git a/src/oklch/definition.js b/src/oklch/definition.js index 418302b1..1a88ae84 100644 --- a/src/oklch/definition.js +++ b/src/oklch/definition.js @@ -3,6 +3,7 @@ import convertLabToLch from '../lch/convertLabToLch.js'; import convertLchToLab from '../lch/convertLchToLab.js'; import convertOklabToRgb from '../oklab/convertOklabToRgb.js'; import convertRgbToOklab from '../oklab/convertRgbToOklab.js'; +import parseOklch from './parseOklch.js'; const definition = { ...lch, @@ -18,8 +19,11 @@ const definition = { oklab: c => convertLabToLch(c, 'oklch') }, - parse: ['--oklch'], - serialize: '--oklch', + parse: [parseOklch, '--oklch'], + serialize: c => + `oklch(${c.l !== undefined ? c.l : 'none'} ${ + c.c !== undefined ? c.c : 'none' + } ${c.h || 0}${c.alpha < 1 ? ` / ${c.alpha}` : ''})`, ranges: { l: [0, 0.999], diff --git a/src/oklch/parseOklch.js b/src/oklch/parseOklch.js new file mode 100644 index 00000000..51e4de6b --- /dev/null +++ b/src/oklch/parseOklch.js @@ -0,0 +1,31 @@ +import { Tok } from '../parse.js'; + +function parseOklch(color, parsed) { + if (!parsed || parsed[0] !== 'oklch') { + return undefined; + } + const res = { mode: 'oklch' }; + const [, l, c, h, alpha] = parsed; + if (l.type !== Tok.None) { + if (l.type === Tok.Hue) { + return undefined; + } + res.l = l.value; + } + if (c.type !== Tok.None) { + res.c = Math.max(0, c.type === Tok.Number ? c.value : c.value / 0.4); + } + if (h.type !== Tok.None) { + if (h.type === Tok.Percentage) { + return undefined; + } + res.h = h.value; + } + if (alpha.type !== Tok.None) { + res.alpha = alpha.type === Tok.Number ? alpha.value : alpha.value / 100; + } + + return res; +} + +export default parseOklch; diff --git a/src/parse.js b/src/parse.js index c041aa96..82afeaba 100644 --- a/src/parse.js +++ b/src/parse.js @@ -6,7 +6,7 @@ const IdentStartCodePoint = /[^\x00-\x7F]|[a-zA-Z_]/; /* eslint-disable-next-line no-control-regex */ const IdentCodePoint = /[^\x00-\x7F]|[-\w]/; -export const Tokens = { +export const Tok = { Function: 'function', Ident: 'ident', Number: 'number', @@ -94,15 +94,15 @@ function num(chars) { if (is_ident(chars)) { let id = ident(chars); if (/deg|rad|turn|grad/.test(id)) { - return { type: Tokens.Hue, value: value * huenits[id] }; + return { type: Tok.Hue, value: value * huenits[id] }; } return undefined; } if (chars[chars._i] === '%') { chars._i++; - return { type: Tokens.Percentage, value: +value }; + return { type: Tok.Percentage, value: +value }; } - return { type: Tokens.Number, value: +value }; + return { type: Tok.Number, value: +value }; } /* @@ -134,12 +134,12 @@ function identlike(chars) { let v = ident(chars); if (chars[chars._i] === '(') { chars._i++; - return { type: Tokens.Function, value: v }; + return { type: Tok.Function, value: v }; } if (v === 'none') { - return { type: Tokens.None, value: undefined }; + return { type: Tok.None, value: undefined }; } - return { type: Tokens.Ident, value: v }; + return { type: Tok.Ident, value: v }; } export function tokenize(str = '') { @@ -171,7 +171,7 @@ export function tokenize(str = '') { } if (ch === ')') { - tokens.push({ type: Tokens.ParenClose }); + tokens.push({ type: Tok.ParenClose }); continue; } @@ -191,7 +191,7 @@ export function tokenize(str = '') { continue; } else if (is_ident(chars)) { chars._i--; - tokens.push({ type: Tokens.Ident, value: ident(chars) }); + tokens.push({ type: Tok.Ident, value: ident(chars) }); continue; } return undefined; @@ -218,16 +218,16 @@ export function tokenize(str = '') { let alpha; if (is_num(chars)) { alpha = num(chars); - if (alpha.type !== Tokens.Hue) { - tokens.push({ type: Tokens.Alpha, value: alpha }); + if (alpha.type !== Tok.Hue) { + tokens.push({ type: Tok.Alpha, value: alpha }); continue; } } if (is_ident(chars)) { if (ident(chars) === 'none') { tokens.push({ - type: Tokens.Alpha, - value: { type: Tokens.None, value: undefined } + type: Tok.Alpha, + value: { type: Tok.None, value: undefined } }); continue; } @@ -259,12 +259,11 @@ export function tokenize(str = '') { export function parseColorSyntax(tokens) { tokens._i = 0; let token = tokens[tokens._i++]; - if (!token || token.type !== Tokens.Function || token.value !== 'color') { + if (!token || token.type !== Tok.Function || token.value !== 'color') { return undefined; } token = tokens[tokens._i++]; - - if (token.type !== Tokens.Ident) { + if (token.type !== Tok.Ident) { return undefined; } const mode = colorProfiles[token.value]; @@ -272,22 +271,17 @@ export function parseColorSyntax(tokens) { return undefined; } const res = { mode }; - const modeDef = getMode(mode); - if (!modeDef) { - return undefined; - } const coords = consumeCoords(tokens, false); if (!coords) { return undefined; } - for (let ii = 0, c; ii < modeDef.channels.length; ii++) { + const channels = getMode(mode).channels; + for (let ii = 0, c; ii < channels.length; ii++) { c = coords[ii]; - if (c.type !== Tokens.None) { - res[modeDef.channels[ii]] = - c.type === Tokens.Number ? c.value : c.value / 100; + if (c.type !== Tok.None) { + res[channels[ii]] = c.type === Tok.Number ? c.value : c.value / 100; } } - return res; } @@ -297,16 +291,16 @@ function consumeCoords(tokens, includeHue) { while (tokens._i < tokens.length) { token = tokens[tokens._i++]; if ( - token.type === Tokens.None || - token.type === Tokens.Number || - token.type === Tokens.Alpha || - token.type === Tokens.Percentage || - token.type === Tokens.Hue + token.type === Tok.None || + token.type === Tok.Number || + token.type === Tok.Alpha || + token.type === Tok.Percentage || + token.type === Tok.Hue ) { coords.push(token); continue; } - if (token.type === Tokens.ParenClose) { + if (token.type === Tok.ParenClose) { if (tokens._i < tokens.length) { return undefined; } @@ -320,22 +314,22 @@ function consumeCoords(tokens, includeHue) { } if (coords.length === 4) { - if (coords[3].type !== Tokens.Alpha) { + if (coords[3].type !== Tok.Alpha) { return undefined; } coords[3] = coords[3].value; } if (coords.length === 3) { - coords.push({ type: Tokens.None, value: undefined }); + coords.push({ type: Tok.None, value: undefined }); } - return coords.every(c => c.type !== Tokens.Alpha) ? coords : undefined; + return coords.every(c => c.type !== Tok.Alpha) ? coords : undefined; } export function parseModernSyntax(tokens, includeHue) { tokens._i = 0; let token = tokens[tokens._i++]; - if (!token || token.type !== Tokens.Function) { + if (!token || token.type !== Tok.Function) { return undefined; } let coords = consumeCoords(tokens, includeHue); diff --git a/src/rgb/definition.js b/src/rgb/definition.js index d1974fe0..01b5bd96 100644 --- a/src/rgb/definition.js +++ b/src/rgb/definition.js @@ -1,7 +1,7 @@ import parseNamed from './parseNamed.js'; import parseHex from './parseHex.js'; +import parseRgbLegacy from './parseRgbLegacy.js'; import parseRgb from './parseRgb.js'; -import parseRgbModern from './parseRgbModern.js'; import parseTransparent from './parseTransparent.js'; import { interpolatorLinear } from '../interpolate/linear.js'; import { fixupAlpha } from '../fixup/alpha.js'; @@ -14,15 +14,14 @@ const definition = { mode: 'rgb', channels: ['r', 'g', 'b', 'alpha'], parse: [ - parseHex, - parseRgbModern, parseRgb, + parseHex, + parseRgbLegacy, parseNamed, parseTransparent, 'srgb' ], serialize: 'srgb', - interpolate: { r: interpolatorLinear, g: interpolatorLinear, diff --git a/src/rgb/parseRgb.js b/src/rgb/parseRgb.js index 6ffb8e0b..3dab4269 100644 --- a/src/rgb/parseRgb.js +++ b/src/rgb/parseRgb.js @@ -1,51 +1,28 @@ -import { num, per, num_per, c } from '../util/regex.js'; +import { Tok } from '../parse.js'; -/* - rgb() regular expressions for legacy format - Reference: https://drafts.csswg.org/css-color/#rgb-functions - */ -const rgb_num_old = new RegExp( - `^rgba?\\(\\s*${num}${c}${num}${c}${num}\\s*(?:,\\s*${num_per}\\s*)?\\)$` -); - -const rgb_per_old = new RegExp( - `^rgba?\\(\\s*${per}${c}${per}${c}${per}\\s*(?:,\\s*${num_per}\\s*)?\\)$` -); - -const parseRgb = color => { - let res = { mode: 'rgb' }; - let match; - if ((match = color.match(rgb_num_old))) { - if (match[1] !== undefined) { - res.r = match[1] / 255; - } - if (match[2] !== undefined) { - res.g = match[2] / 255; - } - if (match[3] !== undefined) { - res.b = match[3] / 255; - } - } else if ((match = color.match(rgb_per_old))) { - if (match[1] !== undefined) { - res.r = match[1] / 100; - } - if (match[2] !== undefined) { - res.g = match[2] / 100; - } - if (match[3] !== undefined) { - res.b = match[3] / 100; - } - } else { +function parseRgb(color, parsed) { + if (!parsed || (parsed[0] !== 'rgb' && parsed[0] !== 'rgba')) { return undefined; } - - if (match[4] !== undefined) { - res.alpha = match[4] / 100; - } else if (match[5] !== undefined) { - res.alpha = +match[5]; + const res = { mode: 'rgb' }; + const [, r, g, b, alpha] = parsed; + if (r.type === Tok.Hue || g.type === Tok.Hue || b.type === Tok.Hue) { + return undefined; + } + if (r.type !== Tok.None) { + res.r = r.type === Tok.Number ? r.value / 255 : r.value / 100; + } + if (g.type !== Tok.None) { + res.g = g.type === Tok.Number ? g.value / 255 : g.value / 100; + } + if (b.type !== Tok.None) { + res.b = b.type === Tok.Number ? b.value / 255 : b.value / 100; + } + if (alpha.type !== Tok.None) { + res.alpha = alpha.type === Tok.Number ? alpha.value : alpha.value / 100; } return res; -}; +} export default parseRgb; diff --git a/src/rgb/parseRgbLegacy.js b/src/rgb/parseRgbLegacy.js new file mode 100644 index 00000000..bfd27ff0 --- /dev/null +++ b/src/rgb/parseRgbLegacy.js @@ -0,0 +1,51 @@ +import { num, per, num_per, c } from '../util/regex.js'; + +/* + rgb() regular expressions for legacy format + Reference: https://drafts.csswg.org/css-color/#rgb-functions + */ +const rgb_num_old = new RegExp( + `^rgba?\\(\\s*${num}${c}${num}${c}${num}\\s*(?:,\\s*${num_per}\\s*)?\\)$` +); + +const rgb_per_old = new RegExp( + `^rgba?\\(\\s*${per}${c}${per}${c}${per}\\s*(?:,\\s*${num_per}\\s*)?\\)$` +); + +const parseRgbLegacy = color => { + let res = { mode: 'rgb' }; + let match; + if ((match = color.match(rgb_num_old))) { + if (match[1] !== undefined) { + res.r = match[1] / 255; + } + if (match[2] !== undefined) { + res.g = match[2] / 255; + } + if (match[3] !== undefined) { + res.b = match[3] / 255; + } + } else if ((match = color.match(rgb_per_old))) { + if (match[1] !== undefined) { + res.r = match[1] / 100; + } + if (match[2] !== undefined) { + res.g = match[2] / 100; + } + if (match[3] !== undefined) { + res.b = match[3] / 100; + } + } else { + return undefined; + } + + if (match[4] !== undefined) { + res.alpha = match[4] / 100; + } else if (match[5] !== undefined) { + res.alpha = +match[5]; + } + + return res; +}; + +export default parseRgbLegacy; diff --git a/src/rgb/parseRgbModern.js b/src/rgb/parseRgbModern.js deleted file mode 100644 index 427d61e9..00000000 --- a/src/rgb/parseRgbModern.js +++ /dev/null @@ -1,36 +0,0 @@ -import { Tokens } from '../parse.js'; - -function parseRgbModern(color, parsed) { - if (!parsed) { - return undefined; - } - if (parsed[0] !== 'rgb' && parsed[0] !== 'rgba') { - return undefined; - } - const res = { mode: 'rgb' }; - const [_, r, g, b, alpha] = parsed; - if ( - r.type === Tokens.Hue || - g.type === Tokens.Hue || - b.type === Tokens.Hue - ) { - return undefined; - } - if (r.type !== Tokens.None) { - res.r = r.type === Tokens.Number ? r.value / 255 : r.value / 100; - } - if (g.type !== Tokens.None) { - res.g = g.type === Tokens.Number ? g.value / 255 : g.value / 100; - } - if (b.type !== Tokens.None) { - res.b = b.type === Tokens.Number ? b.value / 255 : b.value / 100; - } - if (alpha.type !== Tokens.None) { - res.alpha = - alpha.type === Tokens.Number ? alpha.value : alpha.value / 100; - } - - return res; -} - -export default parseRgbModern; diff --git a/test/api.test.js b/test/api.test.js index 844160fb..1bba3e8f 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -187,11 +187,15 @@ const API_FULL = [ 'parse', 'parseHex', 'parseHsl', + 'parseHslLegacy', 'parseHwb', 'parseLab', 'parseLch', 'parseNamed', + 'parseOklab', + 'parseOklch', 'parseRgb', + 'parseRgbLegacy', 'parseTransparent', 'prophoto', 'random', @@ -420,11 +424,15 @@ const API_FN = [ 'parse', 'parseHex', 'parseHsl', + 'parseHslLegacy', 'parseHwb', 'parseLab', 'parseLch', 'parseNamed', + 'parseOklab', + 'parseOklch', 'parseRgb', + 'parseRgbLegacy', 'parseTransparent', 'random', 'removeParser', diff --git a/test/oklab.test.js b/test/oklab.test.js index 3a7fe4c4..0842490c 100644 --- a/test/oklab.test.js +++ b/test/oklab.test.js @@ -40,10 +40,21 @@ tape('color(--oklab)', t => { t.end(); }); +tape('oklab()', t => { + t.deepEqual(oklab('oklab(30 0.5 1 / 0.25)'), { + l: 30, + a: 0.5, + b: 1, + alpha: 0.25, + mode: 'oklab' + }); + t.end(); +}); + tape('formatCss', t => { t.equal( formatCss('color(--oklab 30 0.5 1 / 0.25)'), - 'color(--oklab 30 0.5 1 / 0.25)' + 'oklab(30 0.5 1 / 0.25)' ); t.end(); }); diff --git a/test/oklch.test.js b/test/oklch.test.js index 0db71cc8..f3e8bbb6 100644 --- a/test/oklch.test.js +++ b/test/oklch.test.js @@ -26,6 +26,17 @@ tape('oklch', t => { t.end(); }); +tape('oklch()', t => { + t.deepEqual(oklch('oklch(30 0.5 1 / 0.25)'), { + l: 30, + c: 0.5, + h: 1, + alpha: 0.25, + mode: 'oklch' + }); + t.end(); +}); + tape('color(--oklch)', t => { t.deepEqual(oklch('color(--oklch 30 0.5 1 / 0.25)'), { l: 30, @@ -40,7 +51,7 @@ tape('color(--oklch)', t => { tape('formatCss', t => { t.equal( formatCss('color(--oklch 30 0.5 1 / 0.25)'), - 'color(--oklch 30 0.5 1 / 0.25)' + 'oklch(30 0.5 1 / 0.25)' ); t.end(); }); From 3af96600bbda57eb2ea2c222f4e039a40103c7eb Mon Sep 17 00:00:00 2001 From: Dan Burzo Date: Thu, 23 Feb 2023 13:10:45 +0200 Subject: [PATCH 4/7] Add tests for #187 --- test/rgb.test.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/rgb.test.js b/test/rgb.test.js index 16b6ef12..cadb36f7 100644 --- a/test/rgb.test.js +++ b/test/rgb.test.js @@ -83,3 +83,27 @@ tape('formatCss', t => { ); t.end(); }); + +/* + See: https://emnudge.dev/blog/perfect-rgb-regex/ +*/ +tape('exotic species', t => { + t.equal( + formatCss(rgb('rgb(1-2-3)')), + 'color(srgb 0.00392156862745098 -0.00784313725490196 -0.011764705882352941)' + ); + t.equal( + formatCss(rgb('rgb(1-.2.3)')), + 'color(srgb 0.00392156862745098 -0.0007843137254901962 0.001176470588235294)' + ); + t.equal( + formatCss(rgb('rgb(1 .2.3)')), + 'color(srgb 0.00392156862745098 0.0007843137254901962 0.001176470588235294)' + ); + t.equal( + formatCss(rgb('rgb(.1.2.3)')), + 'color(srgb 0.0003921568627450981 0.0007843137254901962 0.001176470588235294)' + ); + t.equal(formatCss(rgb('rgb(1.2.3)')), undefined); + t.end(); +}); From 51a6501e76f805b73221ef57edb048a435c56b3f Mon Sep 17 00:00:00 2001 From: Dan Burzo Date: Thu, 23 Feb 2023 13:31:02 +0200 Subject: [PATCH 5/7] Add tests & fixes re #167, #155, #153 --- src/lab/parseLab.js | 4 ++-- src/lch/parseLch.js | 5 ++++- src/oklab/parseOklab.js | 6 +++--- src/oklch/parseOklch.js | 7 +++++-- test/lab.test.js | 8 ++++++++ test/lch.test.js | 14 ++++++++++++++ test/oklab.test.js | 7 +++++++ test/oklch.test.js | 7 +++++++ 8 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/lab/parseLab.js b/src/lab/parseLab.js index 07f97859..78ea6b3d 100644 --- a/src/lab/parseLab.js +++ b/src/lab/parseLab.js @@ -13,10 +13,10 @@ function parseLab(color, parsed) { res.l = l.value; } if (a.type !== Tok.None) { - res.a = a.type === Tok.Number ? a.value : a.value / 125; + res.a = a.type === Tok.Number ? a.value : (a.value * 125) / 100; } if (b.type !== Tok.None) { - res.b = b.type === Tok.Number ? b.value : b.value / 125; + res.b = b.type === Tok.Number ? b.value : (b.value * 125) / 100; } if (alpha.type !== Tok.None) { res.alpha = alpha.type === Tok.Number ? alpha.value : alpha.value / 100; diff --git a/src/lch/parseLch.js b/src/lch/parseLch.js index d5a6d997..d3a6fa13 100644 --- a/src/lch/parseLch.js +++ b/src/lch/parseLch.js @@ -13,7 +13,10 @@ function parseLch(color, parsed) { res.l = l.value; } if (c.type !== Tok.None) { - res.c = Math.max(0, c.type === Tok.Number ? c.value : c.value / 150); + res.c = Math.max( + 0, + c.type === Tok.Number ? c.value : (c.value * 150) / 100 + ); } if (h.type !== Tok.None) { if (h.type === Tok.Percentage) { diff --git a/src/oklab/parseOklab.js b/src/oklab/parseOklab.js index 345a0064..0cb92797 100644 --- a/src/oklab/parseOklab.js +++ b/src/oklab/parseOklab.js @@ -10,13 +10,13 @@ function parseOklab(color, parsed) { return undefined; } if (l.type !== Tok.None) { - res.l = l.value; + res.l = l.type === Tok.Number ? l.value : l.value / 100; } if (a.type !== Tok.None) { - res.a = a.type === Tok.Number ? a.value : a.value / 0.4; + res.a = a.type === Tok.Number ? a.value : (a.value * 0.4) / 100; } if (b.type !== Tok.None) { - res.b = b.type === Tok.Number ? b.value : b.value / 0.4; + res.b = b.type === Tok.Number ? b.value : (b.value * 0.4) / 100; } if (alpha.type !== Tok.None) { res.alpha = alpha.type === Tok.Number ? alpha.value : alpha.value / 100; diff --git a/src/oklch/parseOklch.js b/src/oklch/parseOklch.js index 51e4de6b..b4d0c3d8 100644 --- a/src/oklch/parseOklch.js +++ b/src/oklch/parseOklch.js @@ -10,10 +10,13 @@ function parseOklch(color, parsed) { if (l.type === Tok.Hue) { return undefined; } - res.l = l.value; + res.l = c.type === Tok.Number ? l.value : l.value / 100; } if (c.type !== Tok.None) { - res.c = Math.max(0, c.type === Tok.Number ? c.value : c.value / 0.4); + res.c = Math.max( + 0, + c.type === Tok.Number ? c.value : (c.value * 0.4) / 100 + ); } if (h.type !== Tok.None) { if (h.type === Tok.Percentage) { diff --git a/test/lab.test.js b/test/lab.test.js index 71ffae86..b87e422d 100644 --- a/test/lab.test.js +++ b/test/lab.test.js @@ -26,6 +26,14 @@ tape('lab', t => { }, 'red' ); + + t.deepEqual(lab('lab(50% -10% 200% / 10%)'), { + mode: 'lab', + l: 50, + a: -12.5, + b: 250, + alpha: 0.1 + }); t.end(); }); diff --git a/test/lch.test.js b/test/lch.test.js index c7ad3996..347a127b 100644 --- a/test/lch.test.js +++ b/test/lch.test.js @@ -23,6 +23,20 @@ tape('lch', t => { }, 'red' ); + t.deepEqual(lch('lch(20% 30% .5turn / 10%)'), { + mode: 'lch', + l: 20, + c: 45, + h: 180, + alpha: 0.1 + }); + t.deepEqual(lch('lch(20% -30% .5turn / 10%)'), { + mode: 'lch', + l: 20, + c: 0, + h: 180, + alpha: 0.1 + }); t.end(); }); diff --git a/test/oklab.test.js b/test/oklab.test.js index 0842490c..bd62abec 100644 --- a/test/oklab.test.js +++ b/test/oklab.test.js @@ -48,6 +48,13 @@ tape('oklab()', t => { alpha: 0.25, mode: 'oklab' }); + t.deepEqual(oklab('oklab(25% -20% 125% / 15%)'), { + mode: 'oklab', + l: 0.25, + a: -0.08, + b: 0.5, + alpha: 0.15 + }); t.end(); }); diff --git a/test/oklch.test.js b/test/oklch.test.js index f3e8bbb6..7f2b08fd 100644 --- a/test/oklch.test.js +++ b/test/oklch.test.js @@ -34,6 +34,13 @@ tape('oklch()', t => { alpha: 0.25, mode: 'oklch' }); + t.deepEqual(oklch('oklch(40% 50% .5turn / 15%)'), { + mode: 'oklch', + l: 0.4, + c: 0.2, + h: 180, + alpha: 0.15 + }); t.end(); }); From 0255a6209eea4c2f4e66275faedd8b521c4fc9c7 Mon Sep 17 00:00:00 2001 From: Dan Burzo Date: Thu, 23 Feb 2023 13:51:20 +0200 Subject: [PATCH 6/7] Update the ranges for lab/lch/oklab/oklch to match CSS reference ranges. --- docs/api.md | 2 +- docs/color-spaces.md | 26 +++++++++++++------------- src/lab/definition.js | 4 ++-- src/lch/definition.js | 2 +- src/modes.js | 11 ++--------- src/oklab/definition.js | 6 +++--- src/oklch/definition.js | 4 ++-- 7 files changed, 24 insertions(+), 31 deletions(-) diff --git a/docs/api.md b/docs/api.md index 3c45fd5f..adc849f3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1516,7 +1516,7 @@ The opposite of `toMode`. A set of function to convert from various color spaces #### `ranges` (_object_, optional) -The ranges for values in specific channels; if left unspecified, defaults to `[0, 1]`. +The reference ranges for values in specific channels; if left unspecified, defaults to `[0, 1]`. #### `parse` (_array_, optional) diff --git a/docs/color-spaces.md b/docs/color-spaces.md index 0ff41dbf..2bf4c0d9 100644 --- a/docs/color-spaces.md +++ b/docs/color-spaces.md @@ -138,11 +138,11 @@ The [CIELAB color space](https://en.wikipedia.org/wiki/CIELAB_color_space), also The CIELAB color space using the [D50 standard illuminant](https://en.wikipedia.org/wiki/Standard_illuminant) as the reference white, following the [CSS Color Module Level 4 specification](https://drafts.csswg.org/css-color/#lab-colors). -| Channel | Range | Description | +| Channel | CSS Reference Range | Description | | ------- | --------------------- | --------------------- | | `l` | `[0, 100]` | Lightness | -| `a` | `[-79.287, 93.55]`≈ | Green–red component | -| `b` | `[-112.029, 93.388]`≈ | Blue–yellow component | +| `a` | `[-100, 100]` | Green–red component | +| `b` | `[-100, 100]` | Blue–yellow component | Serialized as `lab(l% a b)`, with the `none` keyword for any missing color channel. An explicit `alpha < 1` is included as ` / alpha`. @@ -150,10 +150,10 @@ Serialized as `lab(l% a b)`, with the `none` keyword for any missing color chann The CIELCh color space using the D50 standard illuminant. -| Channel | Range | Description | +| Channel | CSS Reference Range | Description | | ------- | --------------- | ----------- | | `l` | `[0, 100]` | Lightness | -| `c` | `[0, 131.207]`≈ | Chroma | +| `c` | `[0, 150]` | Chroma | | `h` | `[0, 360)` | Hue | Serialized as `lch(l% c h)`. A missing hue is serialized as `0`, with the `none` keyword for any other missing color channel. An explicit `alpha < 1` is included as ` / alpha`. @@ -252,13 +252,13 @@ See also: [Okhsl and Okhsv, two new color spaces for color picking](https://bott The Oklab color space in Cartesian form. -| Channel | Range | Description | +| Channel | CSS Reference Range | Description | | ------- | ------------------ | --------------------- | -| `l` | `[0, 0.999]`≈ | Lightness | -| `a` | `[-0.233, 0.276]`≈ | Green–red component | -| `b` | `[-0.311, 0.198]`≈ | Blue–yellow component | +| `l` | `[0, 1]` | Lightness | +| `a` | `[-0.4, 0.4]` | Green–red component | +| `b` | `[-0.4, 0.4]` | Blue–yellow component | -Serialized as `color(--oklab l a b)`, with the `none` keyword for any missing color channel. An explicit `alpha < 1` is included as ` / alpha`. +Serialized as `oklab(l a b)`, with the `none` keyword for any missing color channel. An explicit `alpha < 1` is included as ` / alpha`. #### `oklch` @@ -266,11 +266,11 @@ The Oklab color space in cylindrical form. | Channel | Range | Description | | ------- | ------------- | ----------- | -| `l` | `[0, 0.999]`≈ | Lightness | -| `c` | `[0, 0.322]`≈ | Chroma | +| `l` | `[0, 1]` | Lightness | +| `c` | `[0, 0.4]` | Chroma | | `h` | `[0, 360)` | Hue | -Serialized as `color(--oklch l c h)`, with the `none` keyword for any missing color channel. An explicit `alpha < 1` is included as ` / alpha`. +Serialized as `oklch(l c h)`, with the `none` keyword for any missing color channel. An explicit `alpha < 1` is included as ` / alpha`. ### `okhsl` diff --git a/src/lab/definition.js b/src/lab/definition.js index 202f7815..76687ee9 100644 --- a/src/lab/definition.js +++ b/src/lab/definition.js @@ -23,8 +23,8 @@ const definition = { ranges: { l: [0, 100], - a: [-79.287, 93.55], - b: [-112.029, 93.388] + a: [-100, 100], + b: [-100, 100] }, parse: [parseLab], diff --git a/src/lch/definition.js b/src/lch/definition.js index 767a1775..47081de9 100644 --- a/src/lch/definition.js +++ b/src/lch/definition.js @@ -26,7 +26,7 @@ const definition = { ranges: { l: [0, 100], - c: [0, 131.207], + c: [0, 150], h: [0, 360] }, diff --git a/src/modes.js b/src/modes.js index 85e44a61..3bb1dc60 100644 --- a/src/modes.js +++ b/src/modes.js @@ -5,7 +5,6 @@ const modes = {}; const parsers = []; const colorProfiles = {}; -const modernColorFunctions = {}; const identity = v => v; @@ -57,12 +56,7 @@ const useMode = definition => { if (typeof parser === 'function') { useParser(parser); } else if (typeof parser === 'string') { - const m = parser.match(/^(.+)\(\)$/); - if (m) { - modernColorFunctions[m[1]] = definition.mode; - } else { - colorProfiles[parser] = definition.mode; - } + colorProfiles[parser] = definition.mode; } }); @@ -92,6 +86,5 @@ export { removeParser, converters, parsers, - colorProfiles, - modernColorFunctions + colorProfiles }; diff --git a/src/oklab/definition.js b/src/oklab/definition.js index 7891d78f..add4b3da 100644 --- a/src/oklab/definition.js +++ b/src/oklab/definition.js @@ -26,9 +26,9 @@ const definition = { }, ranges: { - l: [0, 0.999], - a: [-0.233, 0.276], - b: [-0.311, 0.198] + l: [0, 1], + a: [-0.4, 0.4], + b: [-0.4, 0.4] }, parse: [parseOklab, '--oklab'], diff --git a/src/oklch/definition.js b/src/oklch/definition.js index 1a88ae84..57aa7a1a 100644 --- a/src/oklch/definition.js +++ b/src/oklch/definition.js @@ -26,8 +26,8 @@ const definition = { } ${c.h || 0}${c.alpha < 1 ? ` / ${c.alpha}` : ''})`, ranges: { - l: [0, 0.999], - c: [0, 0.322], + l: [0, 1], + c: [0, 0.4], h: [0, 360] } }; From 85d50c5aec38af0c5a05480236a5f01617780c25 Mon Sep 17 00:00:00 2001 From: Dan Burzo Date: Thu, 23 Feb 2023 13:55:24 +0200 Subject: [PATCH 7/7] Add docs on newly-exported parse fns --- docs/api.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/api.md b/docs/api.md index adc849f3..b535c2fa 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1635,7 +1635,11 @@ parseHex('#abcdef12'); # __parseHsl__(_string_) → _color_ -Parses `hsl(…)` / `hsla(…)` strings and returns `hsl` color objects. +Parses `hsl(…)` strings in the modern format and returns `hsl` color objects. + +# __parseHslLegacy__(_string_) → _color_ + +Parses `hsl(…)` / `hsla(…)` strings in the legacy (comma-separated) format and returns `hsl` color objects. # __parseHwb__(_string_) → _color_ @@ -1653,9 +1657,21 @@ Parses `lch(…)` strings and returns `lch` color objects. Parses named CSS colors (eg. `tomato`) and returns `rgb` color objects. +# __parseOklab__(_string_) → _color_ + +Parses `oklab(…)` strings and returns `oklab` color objects. + +# __parseOklch__(_string_) → _color_ + +Parses `oklch(…)` strings and returns `oklch` color objects. + # __parseRgb__(_color_) → _color_ -Parses `rgb(…)` / `rgba(…)` strings and returns `rgb` color objects. +Parses `rgb(…)` strings in the modern syntax and returns `rgb` color objects. + +# __parseRgbLegacy__(_color_) → _color_ + +Parses `rgb(…)` / `rgba(…)` strings in the legacy (comma-separated) syntax and returns `rgb` color objects. #__parseTransparent__(_string_) → _color_