color.js (19539B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const COLOR_UNIT_PREF = "devtools.defaultColorUnit"; 8 const SPECIALVALUES = new Set([ 9 "currentcolor", 10 "initial", 11 "inherit", 12 "transparent", 13 "unset", 14 ]); 15 16 /** 17 * This module is used to convert between various color types. 18 * 19 * Usage: 20 * let {colorUtils} = require("devtools/shared/css/color"); 21 * let color = new colorUtils.CssColor("red"); 22 * // In order to support css-color-4 color function, pass true to the 23 * // second argument. 24 * // e.g. 25 * // let color = new colorUtils.CssColor("red", true); 26 * 27 * color.authored === "red" 28 * color.hasAlpha === false 29 * color.valid === true 30 * color.transparent === false // transparent has a special status. 31 * color.name === "red" // returns hex when no name available. 32 * color.hex === "#f00" // returns shortHex when available else returns 33 * longHex. If alpha channel is present then we 34 * return this.alphaHex if available, 35 * or this.longAlphaHex if not. 36 * color.alphaHex === "#f00f" // returns short alpha hex when available 37 * else returns longAlphaHex. 38 * color.longHex === "#ff0000" // If alpha channel is present then we return 39 * this.longAlphaHex. 40 * color.longAlphaHex === "#ff0000ff" 41 * color.rgb === "rgb(255, 0, 0)" // If alpha channel is present 42 * // then we return this.rgba. 43 * color.rgba === "rgba(255, 0, 0, 1)" 44 * color.hsl === "hsl(0, 100%, 50%)" 45 * color.hsla === "hsla(0, 100%, 50%, 1)" // If alpha channel is present 46 * then we return this.rgba. 47 * color.hwb === "hwb(0, 0%, 0%)" 48 * 49 * color.toString() === "#f00"; // Outputs the color type determined in the 50 * COLOR_UNIT_PREF constant (above). 51 * 52 * Valid values for COLOR_UNIT_PREF are contained in CssColor.COLORUNIT. 53 */ 54 class CssColor { 55 /** 56 * @param {string} colorValue: Any valid color string 57 */ 58 constructor(colorValue) { 59 // Store a lower-cased version of the color to help with format 60 // testing. The original text is kept as well so it can be 61 // returned when needed. 62 this.#lowerCased = colorValue.toLowerCase(); 63 this.#authored = colorValue; 64 this.specialValue = SPECIALVALUES.has(this.#lowerCased) 65 ? this.#authored 66 : null; 67 } 68 69 /** 70 * Values used in COLOR_UNIT_PREF 71 */ 72 static COLORUNIT = { 73 authored: "authored", 74 hex: "hex", 75 name: "name", 76 rgb: "rgb", 77 hsl: "hsl", 78 hwb: "hwb", 79 }; 80 81 // The value as-authored. 82 #authored = null; 83 #currentFormat; 84 // A lower-cased copy of |authored|. 85 #lowerCased = null; 86 87 get hasAlpha() { 88 if (!this.valid) { 89 return false; 90 } 91 return this.getRGBATuple().a !== 1; 92 } 93 94 /** 95 * Return true if the color is a valid color and we can get rgba tuples from it. 96 */ 97 get valid() { 98 // We can't use InspectorUtils.isValidCSSColor as colors can be valid but we can't have 99 // their rgba tuples (e.g. currentColor, accentColor, … whose actual values depends on 100 // additional context we don't have here). 101 return InspectorUtils.colorToRGBA(this.#authored) !== null; 102 } 103 104 /** 105 * Not a real color type but used to preserve accuracy when converting between 106 * e.g. 8 character hex -> rgba -> 8 character hex (hex alpha values are 107 * 0 - 255 but rgba alpha values are only 0.0 to 1.0). 108 */ 109 get highResTuple() { 110 const type = classifyColor(this.#authored); 111 112 if (type === CssColor.COLORUNIT.hex) { 113 return hexToRGBA(this.#authored.substring(1), true); 114 } 115 116 // If we reach this point then the alpha value must be in the range 117 // 0.0 - 1.0 so we need to multiply it by 255. 118 const tuple = InspectorUtils.colorToRGBA(this.#authored); 119 tuple.a *= 255; 120 return tuple; 121 } 122 123 /** 124 * Return true for all transparent values e.g. rgba(0, 0, 0, 0). 125 */ 126 get transparent() { 127 try { 128 const tuple = this.getRGBATuple(); 129 return !(tuple.r || tuple.g || tuple.b || tuple.a); 130 } catch (e) { 131 return false; 132 } 133 } 134 135 get name() { 136 const invalidOrSpecialValue = this.#getInvalidOrSpecialValue(); 137 if (invalidOrSpecialValue !== false) { 138 return invalidOrSpecialValue; 139 } 140 141 const tuple = this.getRGBATuple(); 142 143 if (tuple.a !== 1) { 144 return this.hex; 145 } 146 const { r, g, b } = tuple; 147 return InspectorUtils.rgbToColorName(r, g, b) || this.hex; 148 } 149 150 get hex() { 151 const invalidOrSpecialValue = this.#getInvalidOrSpecialValue(); 152 if (invalidOrSpecialValue !== false) { 153 return invalidOrSpecialValue; 154 } 155 if (this.hasAlpha) { 156 return this.alphaHex; 157 } 158 159 let hex = this.longHex; 160 if ( 161 hex.charAt(1) == hex.charAt(2) && 162 hex.charAt(3) == hex.charAt(4) && 163 hex.charAt(5) == hex.charAt(6) 164 ) { 165 hex = "#" + hex.charAt(1) + hex.charAt(3) + hex.charAt(5); 166 } 167 return hex; 168 } 169 170 get alphaHex() { 171 const invalidOrSpecialValue = this.#getInvalidOrSpecialValue(); 172 if (invalidOrSpecialValue !== false) { 173 return invalidOrSpecialValue; 174 } 175 176 let alphaHex = this.longAlphaHex; 177 if ( 178 alphaHex.charAt(1) == alphaHex.charAt(2) && 179 alphaHex.charAt(3) == alphaHex.charAt(4) && 180 alphaHex.charAt(5) == alphaHex.charAt(6) && 181 alphaHex.charAt(7) == alphaHex.charAt(8) 182 ) { 183 alphaHex = 184 "#" + 185 alphaHex.charAt(1) + 186 alphaHex.charAt(3) + 187 alphaHex.charAt(5) + 188 alphaHex.charAt(7); 189 } 190 return alphaHex; 191 } 192 193 get longHex() { 194 const invalidOrSpecialValue = this.#getInvalidOrSpecialValue(); 195 if (invalidOrSpecialValue !== false) { 196 return invalidOrSpecialValue; 197 } 198 if (this.hasAlpha) { 199 return this.longAlphaHex; 200 } 201 202 const tuple = this.getRGBATuple(); 203 return ( 204 "#" + 205 ((1 << 24) + (tuple.r << 16) + (tuple.g << 8) + (tuple.b << 0)) 206 .toString(16) 207 .substr(-6) 208 ); 209 } 210 211 get longAlphaHex() { 212 const invalidOrSpecialValue = this.#getInvalidOrSpecialValue(); 213 if (invalidOrSpecialValue !== false) { 214 return invalidOrSpecialValue; 215 } 216 217 const tuple = this.highResTuple; 218 219 return ( 220 "#" + 221 ((1 << 24) + (tuple.r << 16) + (tuple.g << 8) + (tuple.b << 0)) 222 .toString(16) 223 .substr(-6) + 224 Math.round(tuple.a).toString(16).padStart(2, "0") 225 ); 226 } 227 228 get rgb() { 229 const invalidOrSpecialValue = this.#getInvalidOrSpecialValue(); 230 if (invalidOrSpecialValue !== false) { 231 return invalidOrSpecialValue; 232 } 233 if (!this.hasAlpha) { 234 if (this.#lowerCased.startsWith("rgb(")) { 235 // The color is valid and begins with rgb(. 236 return this.#authored; 237 } 238 const tuple = this.getRGBATuple(); 239 return "rgb(" + tuple.r + ", " + tuple.g + ", " + tuple.b + ")"; 240 } 241 return this.rgba; 242 } 243 244 get rgba() { 245 const invalidOrSpecialValue = this.#getInvalidOrSpecialValue(); 246 if (invalidOrSpecialValue !== false) { 247 return invalidOrSpecialValue; 248 } 249 if (this.#lowerCased.startsWith("rgba(")) { 250 // The color is valid and begins with rgba(. 251 return this.#authored; 252 } 253 const components = this.getRGBATuple(); 254 return ( 255 "rgba(" + 256 components.r + 257 ", " + 258 components.g + 259 ", " + 260 components.b + 261 ", " + 262 components.a + 263 ")" 264 ); 265 } 266 267 get hsl() { 268 const invalidOrSpecialValue = this.#getInvalidOrSpecialValue(); 269 if (invalidOrSpecialValue !== false) { 270 return invalidOrSpecialValue; 271 } 272 if (this.#lowerCased.startsWith("hsl(")) { 273 // The color is valid and begins with hsl(. 274 return this.#authored; 275 } 276 if (this.hasAlpha) { 277 return this.hsla; 278 } 279 return this.#hsl(); 280 } 281 282 get hsla() { 283 const invalidOrSpecialValue = this.#getInvalidOrSpecialValue(); 284 if (invalidOrSpecialValue !== false) { 285 return invalidOrSpecialValue; 286 } 287 if (this.#lowerCased.startsWith("hsla(")) { 288 // The color is valid and begins with hsla(. 289 return this.#authored; 290 } 291 if (this.hasAlpha) { 292 const a = this.getRGBATuple().a; 293 return this.#hsl(a); 294 } 295 return this.#hsl(1); 296 } 297 298 get hwb() { 299 const invalidOrSpecialValue = this.#getInvalidOrSpecialValue(); 300 if (invalidOrSpecialValue !== false) { 301 return invalidOrSpecialValue; 302 } 303 if (this.#lowerCased.startsWith("hwb(")) { 304 // The color is valid and begins with hwb(. 305 return this.#authored; 306 } 307 if (this.hasAlpha) { 308 const a = this.getRGBATuple().a; 309 return this.#hwb(a); 310 } 311 return this.#hwb(); 312 } 313 314 /** 315 * Check whether the current color value is in the special list e.g. 316 * transparent or invalid. 317 * 318 * @return {string | boolean} 319 * - If the current color is a special value e.g. "transparent" then 320 * return the color. 321 * - If the current color is a system value e.g. "accentcolor" then 322 * return the color. 323 * - If the color is invalid or that we can't get rgba components from it 324 * (e.g. "accentcolor"), return an empty string. 325 * - If the color is a regular color e.g. #F06 so we return false 326 * to indicate that the color is neither invalid or special. 327 */ 328 #getInvalidOrSpecialValue() { 329 if (this.specialValue) { 330 return this.specialValue; 331 } 332 if (!this.valid) { 333 return ""; 334 } 335 return false; 336 } 337 338 nextColorUnit() { 339 // Reorder the formats array to have the current format at the 340 // front so we can cycle through. 341 // Put "name" at the end as that provides a hex value if there's 342 // no name for the color. 343 let formats = ["hex", "hsl", "rgb", "hwb", "name"]; 344 345 let currentFormat = this.#currentFormat; 346 // If we don't have determined the current format yet 347 if (!currentFormat) { 348 // If the pref value is COLORUNIT.authored, get the actual unit from the authored color, 349 // otherwise use the pref value. 350 const defaultFormat = Services.prefs.getCharPref(COLOR_UNIT_PREF); 351 currentFormat = 352 defaultFormat === CssColor.COLORUNIT.authored 353 ? classifyColor(this.#authored) 354 : defaultFormat; 355 } 356 const putOnEnd = formats.splice(0, formats.indexOf(currentFormat)); 357 formats = [...formats, ...putOnEnd]; 358 359 const currentDisplayedColor = this[formats[0]]; 360 361 let colorUnit; 362 for (const format of formats) { 363 if (this[format].toLowerCase() !== currentDisplayedColor.toLowerCase()) { 364 colorUnit = CssColor.COLORUNIT[format]; 365 break; 366 } 367 } 368 369 this.#currentFormat = colorUnit; 370 return this.toString(colorUnit); 371 } 372 373 /** 374 * Return a string representing a color of type defined in COLOR_UNIT_PREF. 375 */ 376 toString(colorUnit, forceUppercase) { 377 let color; 378 379 switch (colorUnit) { 380 case CssColor.COLORUNIT.authored: 381 color = this.#authored; 382 break; 383 case CssColor.COLORUNIT.hex: 384 color = this.hex; 385 break; 386 case CssColor.COLORUNIT.hsl: 387 color = this.hsl; 388 break; 389 case CssColor.COLORUNIT.name: 390 color = this.name; 391 break; 392 case CssColor.COLORUNIT.rgb: 393 color = this.rgb; 394 break; 395 case CssColor.COLORUNIT.hwb: 396 color = this.hwb; 397 break; 398 default: 399 color = this.rgb; 400 } 401 402 if ( 403 forceUppercase || 404 (colorUnit != CssColor.COLORUNIT.authored && 405 colorIsUppercase(this.#authored)) 406 ) { 407 color = color.toUpperCase(); 408 } 409 410 return color; 411 } 412 413 /** 414 * Returns a RGBA 4-Tuple representation of a color or transparent as 415 * appropriate. 416 */ 417 getRGBATuple() { 418 const tuple = InspectorUtils.colorToRGBA(this.#authored); 419 420 tuple.a = parseFloat(tuple.a.toFixed(2)); 421 422 return tuple; 423 } 424 425 #hsl(maybeAlpha) { 426 if (this.#lowerCased.startsWith("hsl(") && maybeAlpha === undefined) { 427 // We can use it as-is. 428 return this.#authored; 429 } 430 431 const { r, g, b } = this.getRGBATuple(); 432 const [h, s, l] = rgbToHsl([r, g, b]); 433 if (maybeAlpha !== undefined) { 434 return "hsla(" + h + ", " + s + "%, " + l + "%, " + maybeAlpha + ")"; 435 } 436 return "hsl(" + h + ", " + s + "%, " + l + "%)"; 437 } 438 439 #hwb(maybeAlpha) { 440 if (this.#lowerCased.startsWith("hwb(") && maybeAlpha === undefined) { 441 // We can use it as-is. 442 return this.#authored; 443 } 444 445 const { r, g, b } = this.getRGBATuple(); 446 const [hue, white, black] = rgbToHwb([r, g, b]); 447 return `hwb(${hue} ${white}% ${black}%${ 448 maybeAlpha !== undefined ? " / " + maybeAlpha : "" 449 })`; 450 } 451 452 /** 453 * This method allows comparison of CssColor objects using ===. 454 */ 455 valueOf() { 456 return this.rgba; 457 } 458 459 /** 460 * Check whether the color is fully transparent (alpha === 0). 461 * 462 * @return {boolean} True if the color is transparent and valid. 463 */ 464 isTransparent() { 465 return this.getRGBATuple().a === 0; 466 } 467 } 468 469 /** 470 * Convert rgb value to hsl 471 * 472 * @param {Array} rgb 473 * Array of rgb values 474 * @return {Array} 475 * Array of hsl values. 476 */ 477 function rgbToHsl([r, g, b]) { 478 r = r / 255; 479 g = g / 255; 480 b = b / 255; 481 482 const max = Math.max(r, g, b); 483 const min = Math.min(r, g, b); 484 let h; 485 let s; 486 const l = (max + min) / 2; 487 488 if (max == min) { 489 h = s = 0; 490 } else { 491 const d = max - min; 492 s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 493 494 switch (max) { 495 case r: 496 h = ((g - b) / d) % 6; 497 break; 498 case g: 499 h = (b - r) / d + 2; 500 break; 501 case b: 502 h = (r - g) / d + 4; 503 break; 504 } 505 h *= 60; 506 if (h < 0) { 507 h += 360; 508 } 509 } 510 511 return [roundTo(h, 1), roundTo(s * 100, 1), roundTo(l * 100, 1)]; 512 } 513 514 /** 515 * Convert RGB value to HWB 516 * 517 * @param {Array} rgb 518 * Array of RGB values 519 * @return {Array} 520 * Array of HWB values. 521 */ 522 function rgbToHwb([r, g, b]) { 523 const hsl = rgbToHsl([r, g, b]); 524 525 r = r / 255; 526 g = g / 255; 527 b = b / 255; 528 529 const white = Math.min(r, g, b); 530 const black = 1 - Math.max(r, g, b); 531 return [roundTo(hsl[0], 1), roundTo(white * 100, 1), roundTo(black * 100, 1)]; 532 } 533 534 function roundTo(number, digits) { 535 const multiplier = Math.pow(10, digits); 536 return Math.round(number * multiplier) / multiplier; 537 } 538 539 /** 540 * Given a color, classify its type as one of the possible color 541 * units, as known by |CssColor.COLORUNIT|. 542 * 543 * @param {string} value 544 * The color, in any form accepted by CSS. 545 * @return {string} 546 * The color classification, one of "rgb", "hsl", "hwb", 547 * "hex", "name", or if no format is recognized, "authored". 548 */ 549 function classifyColor(value) { 550 value = value.toLowerCase(); 551 if (value.startsWith("rgb(") || value.startsWith("rgba(")) { 552 return CssColor.COLORUNIT.rgb; 553 } else if (value.startsWith("hsl(") || value.startsWith("hsla(")) { 554 return CssColor.COLORUNIT.hsl; 555 } else if (value.startsWith("hwb(")) { 556 return CssColor.COLORUNIT.hwb; 557 } else if (/^#[0-9a-f]+$/.exec(value)) { 558 return CssColor.COLORUNIT.hex; 559 } else if (/^[a-z\-]+$/.exec(value)) { 560 return CssColor.COLORUNIT.name; 561 } 562 return CssColor.COLORUNIT.authored; 563 } 564 565 /** 566 * A helper function to convert a hex string like "F0C" or "F0C8" to a color. 567 * 568 * @param {string} name the color string 569 * @param {boolean} highResolution Forces returned alpha value to be in the 570 * range 0 - 255 as opposed to 0.0 - 1.0. 571 * @return {object} an object of the form {r, g, b, a}; or null if the 572 * name was not a valid color 573 */ 574 function hexToRGBA(name, highResolution) { 575 let r, 576 g, 577 b, 578 a = 1; 579 580 if (name.length === 3) { 581 // short hex string (e.g. F0C) 582 r = parseInt(name.charAt(0) + name.charAt(0), 16); 583 g = parseInt(name.charAt(1) + name.charAt(1), 16); 584 b = parseInt(name.charAt(2) + name.charAt(2), 16); 585 } else if (name.length === 4) { 586 // short alpha hex string (e.g. F0CA) 587 r = parseInt(name.charAt(0) + name.charAt(0), 16); 588 g = parseInt(name.charAt(1) + name.charAt(1), 16); 589 b = parseInt(name.charAt(2) + name.charAt(2), 16); 590 a = parseInt(name.charAt(3) + name.charAt(3), 16); 591 592 if (!highResolution) { 593 a /= 255; 594 } 595 } else if (name.length === 6) { 596 // hex string (e.g. FD01CD) 597 r = parseInt(name.charAt(0) + name.charAt(1), 16); 598 g = parseInt(name.charAt(2) + name.charAt(3), 16); 599 b = parseInt(name.charAt(4) + name.charAt(5), 16); 600 } else if (name.length === 8) { 601 // alpha hex string (e.g. FD01CDAB) 602 r = parseInt(name.charAt(0) + name.charAt(1), 16); 603 g = parseInt(name.charAt(2) + name.charAt(3), 16); 604 b = parseInt(name.charAt(4) + name.charAt(5), 16); 605 a = parseInt(name.charAt(6) + name.charAt(7), 16); 606 607 if (!highResolution) { 608 a /= 255; 609 } 610 } else { 611 return null; 612 } 613 if (!highResolution) { 614 a = Math.round(a * 10) / 10; 615 } 616 return { r, g, b, a }; 617 } 618 619 /** 620 * Blend background and foreground colors takign alpha into account. 621 * 622 * @param {Array} foregroundColor 623 * An array with [r,g,b,a] values containing the foreground color. 624 * @param {Array} backgroundColor 625 * An array with [r,g,b,a] values containing the background color. Defaults to 626 * [ 255, 255, 255, 1 ]. 627 * @return {Array} 628 * An array with combined [r,g,b,a] colors. 629 */ 630 function blendColors(foregroundColor, backgroundColor = [255, 255, 255, 1]) { 631 const [fgR, fgG, fgB, fgA] = foregroundColor; 632 const [bgR, bgG, bgB, bgA] = backgroundColor; 633 if (fgA === 1) { 634 return foregroundColor; 635 } 636 637 return [ 638 (1 - fgA) * bgR + fgA * fgR, 639 (1 - fgA) * bgG + fgA * fgG, 640 (1 - fgA) * bgB + fgA * fgB, 641 fgA + bgA * (1 - fgA), 642 ]; 643 } 644 645 /** 646 * TODO: Replace with RelativeLuminanceUtils::ContrastRatio, see bug 1984999. 647 * 648 * Calculates the contrast ratio of 2 rgba tuples based on the formula in 649 * https://www.w3.org/TR/2008/REC-WCAG20-20081211/#visual-audio-contrast7 650 * 651 * @param {Array} backgroundColor An array with [r,g,b,a] values containing 652 * the background color. 653 * @param {Array} textColor An array with [r,g,b,a] values containing 654 * the text color. 655 * @return {number} The calculated luminance. 656 */ 657 function calculateContrastRatio(backgroundColor, textColor) { 658 // Do not modify given colors. 659 backgroundColor = Array.from(backgroundColor); 660 textColor = Array.from(textColor); 661 662 backgroundColor = blendColors(backgroundColor); 663 textColor = blendColors(textColor, backgroundColor); 664 665 const backgroundLuminance = InspectorUtils.relativeLuminance( 666 ...backgroundColor.map(c => c / 255) 667 ); 668 const textLuminance = InspectorUtils.relativeLuminance( 669 ...textColor.map(c => c / 255) 670 ); 671 const ratio = (textLuminance + 0.05) / (backgroundLuminance + 0.05); 672 673 return ratio > 1.0 ? ratio : 1 / ratio; 674 } 675 676 function colorIsUppercase(color) { 677 // Specifically exclude the case where the color is 678 // case-insensitive. This makes it so that "#000" isn't 679 // considered "upper case" for the purposes of color cycling. 680 return color === color.toUpperCase() && color !== color.toLowerCase(); 681 } 682 683 module.exports.colorUtils = { 684 CssColor, 685 rgbToHsl, 686 rgbToHwb, 687 classifyColor, 688 calculateContrastRatio, 689 blendColors, 690 colorIsUppercase, 691 };