1 /** 2 Implements CSS Color Module Level 4. 3 4 See_also: https://www.w3.org/TR/css-color-4/ 5 Copyright: Guillaume Piolat 2018. 6 License: $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost License 1.0) 7 */ 8 module printed.htmlcolors; 9 10 import std.string: format; 11 import std.conv: to; 12 import std.math: PI, floor; 13 14 15 pure @safe: 16 17 /// Parses a HTML color and gives back a RGBA triplet. 18 /// 19 /// Params: 20 /// htmlColorString = A CSS string describing a color. 21 /// 22 /// Returns: 23 /// A 32-bit RGBA color, with each component between 0 and 255. 24 /// 25 /// See_also: https://www.w3.org/TR/css-color-4/ 26 /// 27 /// 28 /// Example: 29 /// --- 30 /// import printed.htmlcolors; 31 /// parseHTMLColor("black"); // all HTML named colors 32 /// parseHTMLColor("#fe85dc"); // hex colors including alpha versions 33 /// parseHTMLColor("rgba(64, 255, 128, 0.24)"); // alpha 34 /// parseHTMLColor("rgb(9e-1, 50%, 128)"); // percentage, floating-point 35 /// parseHTMLColor("hsl(120deg, 25%, 75%)"); // hsv colors 36 /// parseHTMLColor("gray(0.5)"); // gray colors 37 /// parseHTMLColor(" rgb ( 245 , 112 , 74 ) "); // strips whitespace 38 /// --- 39 /// 40 ubyte[4] parseHTMLColor(const(char)[] htmlColorString) 41 { 42 string s = htmlColorString.idup; 43 44 // Add a terminal char (we chose zero) 45 // PERF: remove that allocation 46 s ~= '\0'; 47 48 int index = 0; 49 50 char peek() pure @safe 51 { 52 return s[index]; 53 } 54 55 void next() pure @safe 56 { 57 index++; 58 } 59 60 bool parseChar(char ch) pure @safe 61 { 62 if (peek() == ch) 63 { 64 next; 65 return true; 66 } 67 return false; 68 } 69 70 void expectChar(char ch) pure @safe 71 { 72 if (!parseChar(ch)) 73 throw new Exception(format("Expected char %s in color string", ch)); 74 } 75 76 bool parseString(string s) pure @safe 77 { 78 int save = index; 79 80 for (int i = 0; i < s.length; ++i) 81 { 82 if (!parseChar(s[i])) 83 { 84 index = save; 85 return false; 86 } 87 } 88 return true; 89 } 90 91 bool isWhite(char ch) pure @safe 92 { 93 return ch == ' '; 94 } 95 96 bool isDigit(char ch) pure @safe 97 { 98 return ch >= '0' && ch <= '9'; 99 } 100 101 char expectDigit() pure @safe 102 { 103 char ch = peek(); 104 if (isDigit(ch)) 105 { 106 next; 107 return ch; 108 } 109 else 110 throw new Exception("Expected digit 0-9"); 111 } 112 113 bool parseHexDigit(out int digit) pure @safe 114 { 115 char ch = peek(); 116 if (isDigit(ch)) 117 { 118 next; 119 digit = ch - '0'; 120 return true; 121 } 122 else if (ch >= 'a' && ch <= 'f') 123 { 124 next; 125 digit = 10 + (ch - 'a'); 126 return true; 127 } 128 else if (ch >= 'A' && ch <= 'F') 129 { 130 next; 131 digit = 10 + (ch - 'A'); 132 return true; 133 } 134 else 135 return false; 136 } 137 138 void skipWhiteSpace() pure @safe 139 { 140 while (isWhite(peek())) 141 next; 142 } 143 144 void expectPunct(char ch) pure @safe 145 { 146 skipWhiteSpace(); 147 expectChar(ch); 148 skipWhiteSpace(); 149 } 150 151 ubyte clamp0to255(int a) pure @safe 152 { 153 if (a < 0) return 0; 154 if (a > 255) return 255; 155 return cast(ubyte)a; 156 } 157 158 // See: https://www.w3.org/TR/css-syntax/#consume-a-number 159 double parseNumber() pure @safe 160 { 161 string repr = ""; // PERF: fixed size buffer or reusing input string 162 if (parseChar('+')) 163 {} 164 else if (parseChar('-')) 165 { 166 repr ~= '-'; 167 } 168 while(isDigit(peek())) 169 { 170 repr ~= peek(); 171 next; 172 } 173 if (peek() == '.') 174 { 175 repr ~= '.'; 176 next; 177 repr ~= expectDigit(); 178 while(isDigit(peek())) 179 { 180 repr ~= peek(); 181 next; 182 } 183 } 184 if (peek() == 'e' || peek() == 'E') 185 { 186 repr ~= 'e'; 187 next; 188 if (parseChar('+')) 189 {} 190 else if (parseChar('-')) 191 { 192 repr ~= '-'; 193 } 194 while(isDigit(peek())) 195 { 196 repr ~= peek(); 197 next; 198 } 199 } 200 return to!double(repr); 201 } 202 203 ubyte parseColorValue(double range = 255.0) pure @safe 204 { 205 double num = parseNumber(); 206 bool isPercentage = parseChar('%'); 207 if (isPercentage) 208 num *= (255.0 / 100.0); 209 int c = cast(int)(0.5 + num); // round 210 return clamp0to255(c); 211 } 212 213 ubyte parseOpacity() pure @safe 214 { 215 double num = parseNumber(); 216 bool isPercentage = parseChar('%'); 217 if (isPercentage) 218 num *= 0.01; 219 int c = cast(int)(0.5 + num * 255.0); 220 return clamp0to255(c); 221 } 222 223 double parsePercentage() pure @safe 224 { 225 double num = parseNumber(); 226 expectChar('%'); 227 return num *= 0.01; 228 } 229 230 double parseHueInDegrees() pure @safe 231 { 232 double num = parseNumber(); 233 if (parseString("deg")) 234 return num; 235 else if (parseString("rad")) 236 return num * 360.0 / (2 * PI); 237 else if (parseString("turn")) 238 return num * 360.0; 239 else if (parseString("grad")) 240 return num * 360.0 / 400.0; 241 else 242 { 243 // assume degrees 244 return num; 245 } 246 } 247 248 skipWhiteSpace(); 249 250 ubyte red, green, blue, alpha = 255; 251 252 if (parseChar('#')) 253 { 254 int[8] digits; 255 int numDigits = 0; 256 for (int i = 0; i < 8; ++i) 257 { 258 if (parseHexDigit(digits[i])) 259 numDigits++; 260 else 261 break; 262 } 263 switch(numDigits) 264 { 265 case 4: 266 alpha = cast(ubyte)( (digits[3] << 4) | digits[3]); 267 goto case 3; 268 case 3: 269 red = cast(ubyte)( (digits[0] << 4) | digits[0]); 270 green = cast(ubyte)( (digits[1] << 4) | digits[1]); 271 blue = cast(ubyte)( (digits[2] << 4) | digits[2]); 272 break; 273 case 8: 274 alpha = cast(ubyte)( (digits[6] << 4) | digits[7]); 275 goto case 6; 276 case 6: 277 red = cast(ubyte)( (digits[0] << 4) | digits[1]); 278 green = cast(ubyte)( (digits[2] << 4) | digits[3]); 279 blue = cast(ubyte)( (digits[4] << 4) | digits[5]); 280 break; 281 default: 282 throw new Exception("Expected 3, 4, 6 or 8 digit in hexadecimal color literal"); 283 } 284 } 285 else if (parseString("gray")) 286 { 287 288 skipWhiteSpace(); 289 if (!parseChar('(')) 290 { 291 // This is named color "gray" 292 red = green = blue = 128; 293 } 294 else 295 { 296 skipWhiteSpace(); 297 red = green = blue = parseColorValue(); 298 skipWhiteSpace(); 299 if (parseChar(',')) 300 { 301 // there is an alpha value 302 skipWhiteSpace(); 303 alpha = parseOpacity(); 304 } 305 expectPunct(')'); 306 } 307 } 308 else if (parseString("rgb")) 309 { 310 bool hasAlpha = parseChar('a'); 311 expectPunct('('); 312 red = parseColorValue(); 313 expectPunct(','); 314 green = parseColorValue(); 315 expectPunct(','); 316 blue = parseColorValue(); 317 if (hasAlpha) 318 { 319 expectPunct(','); 320 alpha = parseOpacity(); 321 } 322 expectPunct(')'); 323 } 324 else if (parseString("hsl")) 325 { 326 bool hasAlpha = parseChar('a'); 327 expectPunct('('); 328 double hueDegrees = parseHueInDegrees(); 329 // Convert to turns 330 double hueTurns = hueDegrees / 360.0; 331 hueTurns -= floor(hueTurns); // take remainder 332 double hue = 6.0 * hueTurns; 333 expectPunct(','); 334 double sat = parsePercentage(); 335 expectPunct(','); 336 double light = parsePercentage(); 337 338 if (hasAlpha) 339 { 340 expectPunct(','); 341 alpha = parseOpacity(); 342 } 343 expectPunct(')'); 344 double[3] rgb = convertHSLtoRGB(hue, sat, light); 345 red = clamp0to255( cast(int)(0.5 + 255.0 * rgb[0]) ); 346 green = clamp0to255( cast(int)(0.5 + 255.0 * rgb[1]) ); 347 blue = clamp0to255( cast(int)(0.5 + 255.0 * rgb[2]) ); 348 } 349 else 350 { 351 // Initiate a binary search inside the sorted named color array 352 // See_also: https://en.wikipedia.org/wiki/Binary_search_algorithm 353 354 // Current search range 355 // this range will only reduce because the color names are sorted 356 int L = 0; 357 int R = cast(int)(namedColorKeywords.length); 358 int charPos = 0; 359 360 matchloop: 361 while (true) 362 { 363 // Expect 364 char ch = peek(); 365 if (ch >= 'A' && ch <= 'Z') 366 ch += ('a' - 'A'); 367 if (ch < 'a' || ch > 'z') // not alpha? 368 { 369 // Examine all alive cases. Select the one which have matched entirely. 370 foreach(color; L..R) 371 { 372 if (namedColorKeywords[color].length == charPos)// found it, return as there are no duplicates 373 { 374 // If we have matched all the alpha of the only remaining candidate, we have found a named color 375 uint rgba = namedColorValues[color]; 376 red = (rgba >> 24) & 0xff; 377 green = (rgba >> 16) & 0xff; 378 blue = (rgba >> 8) & 0xff; 379 alpha = (rgba >> 0) & 0xff; 380 break matchloop; 381 } 382 } 383 throw new Exception(format("Unexpected char %s in named color", ch)); 384 } 385 next; 386 387 // PERF: there could be something better with a dichotomy 388 // PERF: can elid search once we've passed the last match 389 bool firstFound = false; 390 int firstFoundIndex = R; 391 int lastFoundIndex = -1; 392 foreach(color; L..R) 393 { 394 // Have we found ch in name[charPos] position? 395 string candidate = namedColorKeywords[color]; 396 bool charIsMatching = (candidate.length > charPos) && (candidate[charPos] == ch); 397 if (!firstFound && charIsMatching) 398 { 399 firstFound = true; 400 firstFoundIndex = color; 401 } 402 if (charIsMatching) 403 lastFoundIndex = color; 404 } 405 406 // Zero candidate remain 407 if (lastFoundIndex < firstFoundIndex) 408 throw new Exception("Can't recognize color string '%s'", s); 409 else 410 { 411 // Several candidate remain, go on and reduce the search range 412 L = firstFoundIndex; 413 R = lastFoundIndex + 1; 414 charPos += 1; 415 } 416 } 417 } 418 419 skipWhiteSpace(); 420 if (!parseChar('\0')) 421 throw new Exception("Expected end of input at the end of color string"); 422 423 return [ red, green, blue, alpha]; 424 } 425 426 private: 427 428 // 147 predefined color + "transparent" 429 static immutable string[147 + 1] namedColorKeywords = 430 [ 431 "aliceblue", "antiquewhite", "aqua", "aquamarine", "azure", "beige", "bisque", "black", 432 "blanchedalmond", "blue", "blueviolet", "brown", "burlywood", "cadetblue", "chartreuse", "chocolate", 433 "coral", "cornflowerblue", "cornsilk", "crimson", "cyan", "darkblue", "darkcyan", "darkgoldenrod", 434 "darkgray", "darkgreen", "darkgrey", "darkkhaki", "darkmagenta", "darkolivegreen", "darkorange", "darkorchid", 435 "darkred","darksalmon","darkseagreen","darkslateblue", "darkslategray", "darkslategrey", "darkturquoise", "darkviolet", 436 "deeppink", "deepskyblue", "dimgray", "dimgrey", "dodgerblue", "firebrick", "floralwhite", "forestgreen", 437 "fuchsia", "gainsboro", "ghostwhite", "gold", "goldenrod", "gray", "green", "greenyellow", 438 "grey", "honeydew", "hotpink", "indianred", "indigo", "ivory", "khaki", "lavender", 439 "lavenderblush","lawngreen","lemonchiffon","lightblue","lightcoral", "lightcyan", "lightgoldenrodyellow", "lightgray", 440 "lightgreen", "lightgrey", "lightpink", "lightsalmon", "lightseagreen", "lightskyblue", "lightslategray", "lightslategrey", 441 "lightsteelblue", "lightyellow", "lime", "limegreen", "linen", "magenta", "maroon", "mediumaquamarine", 442 "mediumblue", "mediumorchid", "mediumpurple", "mediumseagreen", "mediumslateblue", "mediumspringgreen", "mediumturquoise", "mediumvioletred", 443 "midnightblue", "mintcream", "mistyrose", "moccasin", "navajowhite", "navy", "oldlace", "olive", 444 "olivedrab", "orange", "orangered", "orchid", "palegoldenrod", "palegreen", "paleturquoise", "palevioletred", 445 "papayawhip", "peachpuff", "peru", "pink", "plum", "powderblue", "purple", "red", 446 "rosybrown", "royalblue", "saddlebrown", "salmon", "sandybrown", "seagreen", "seashell", "sienna", 447 "silver", "skyblue", "slateblue", "slategray", "slategrey", "snow", "springgreen", "steelblue", 448 "tan", "teal", "thistle", "tomato", "transparent", "turquoise", "violet", "wheat", 449 "white", "whitesmoke", "yellow", "yellowgreen" 450 ]; 451 452 immutable static uint[147 + 1] namedColorValues = 453 [ 454 0xf0f8ffff, 0xfaebd7ff, 0x00ffffff, 0x7fffd4ff, 0xf0ffffff, 0xf5f5dcff, 0xffe4c4ff, 0x000000ff, 455 0xffebcdff, 0x0000ffff, 0x8a2be2ff, 0xa52a2aff, 0xdeb887ff, 0x5f9ea0ff, 0x7fff00ff, 0xd2691eff, 456 0xff7f50ff, 0x6495edff, 0xfff8dcff, 0xdc143cff, 0x00ffffff, 0x00008bff, 0x008b8bff, 0xb8860bff, 457 0xa9a9a9ff, 0x006400ff, 0xa9a9a9ff, 0xbdb76bff, 0x8b008bff, 0x556b2fff, 0xff8c00ff, 0x9932ccff, 458 0x8b0000ff, 0xe9967aff, 0x8fbc8fff, 0x483d8bff, 0x2f4f4fff, 0x2f4f4fff, 0x00ced1ff, 0x9400d3ff, 459 0xff1493ff, 0x00bfffff, 0x696969ff, 0x696969ff, 0x1e90ffff, 0xb22222ff, 0xfffaf0ff, 0x228b22ff, 460 0xff00ffff, 0xdcdcdcff, 0xf8f8ffff, 0xffd700ff, 0xdaa520ff, 0x808080ff, 0x008000ff, 0xadff2fff, 461 0x808080ff, 0xf0fff0ff, 0xff69b4ff, 0xcd5c5cff, 0x4b0082ff, 0xfffff0ff, 0xf0e68cff, 0xe6e6faff, 462 0xfff0f5ff, 0x7cfc00ff, 0xfffacdff, 0xadd8e6ff, 0xf08080ff, 0xe0ffffff, 0xfafad2ff, 0xd3d3d3ff, 463 0x90ee90ff, 0xd3d3d3ff, 0xffb6c1ff, 0xffa07aff, 0x20b2aaff, 0x87cefaff, 0x778899ff, 0x778899ff, 464 0xb0c4deff, 0xffffe0ff, 0x00ff00ff, 0x32cd32ff, 0xfaf0e6ff, 0xff00ffff, 0x800000ff, 0x66cdaaff, 465 0x0000cdff, 0xba55d3ff, 0x9370dbff, 0x3cb371ff, 0x7b68eeff, 0x00fa9aff, 0x48d1ccff, 0xc71585ff, 466 0x191970ff, 0xf5fffaff, 0xffe4e1ff, 0xffe4b5ff, 0xffdeadff, 0x000080ff, 0xfdf5e6ff, 0x808000ff, 467 0x6b8e23ff, 0xffa500ff, 0xff4500ff, 0xda70d6ff, 0xeee8aaff, 0x98fb98ff, 0xafeeeeff, 0xdb7093ff, 468 0xffefd5ff, 0xffdab9ff, 0xcd853fff, 0xffc0cbff, 0xdda0ddff, 0xb0e0e6ff, 0x800080ff, 0xff0000ff, 469 0xbc8f8fff, 0x4169e1ff, 0x8b4513ff, 0xfa8072ff, 0xf4a460ff, 0x2e8b57ff, 0xfff5eeff, 0xa0522dff, 470 0xc0c0c0ff, 0x87ceebff, 0x6a5acdff, 0x708090ff, 0x708090ff, 0xfffafaff, 0x00ff7fff, 0x4682b4ff, 471 0xd2b48cff, 0x008080ff, 0xd8bfd8ff, 0xff6347ff, 0x00000000, 0x40e0d0ff, 0xee82eeff, 0xf5deb3ff, 472 0xffffffff, 0xf5f5f5ff, 0xffff00ff, 0x9acd32ff, 473 ]; 474 475 476 // Reference: https://www.w3.org/TR/css-color-4/#hsl-to-rgb 477 // this algorithm assumes that the hue has been normalized to a number in the half-open range [0, 6), 478 // and the saturation and lightness have been normalized to the range [0, 1]. 479 double[3] convertHSLtoRGB(double hue, double sat, double light) 480 { 481 double t2; 482 if( light <= .5 ) 483 t2 = light * (sat + 1); 484 else 485 t2 = light + sat - (light * sat); 486 double t1 = light * 2 - t2; 487 double r = convertHueToRGB(t1, t2, hue + 2); 488 double g = convertHueToRGB(t1, t2, hue); 489 double b = convertHueToRGB(t1, t2, hue - 2); 490 return [r, g, b]; 491 } 492 493 double convertHueToRGB(double t1, double t2, double hue) 494 { 495 if (hue < 0) 496 hue = hue + 6; 497 if (hue >= 6) 498 hue = hue - 6; 499 if (hue < 1) 500 return (t2 - t1) * hue + t1; 501 else if(hue < 3) 502 return t2; 503 else if(hue < 4) 504 return (t2 - t1) * (4 - hue) + t1; 505 else 506 return t1; 507 } 508 509 unittest 510 { 511 bool doesntParse(string color) 512 { 513 try 514 { 515 parseHTMLColor(color); 516 return false; 517 } 518 catch(Exception e) 519 { 520 return true; 521 } 522 } 523 524 assert(doesntParse("")); 525 526 // #hex colors 527 assert(parseHTMLColor("#aB9") == [0xaa, 0xBB, 0x99, 255]); 528 assert(parseHTMLColor("#aB98") == [0xaa, 0xBB, 0x99, 0x88]); 529 assert(doesntParse("#")); 530 assert(doesntParse("#ab")); 531 assert(parseHTMLColor(" #0f1c4A ") == [0x0f, 0x1c, 0x4a, 255]); 532 assert(parseHTMLColor(" #0f1c4A43 ") == [0x0f, 0x1c, 0x4A, 0x43]); 533 assert(doesntParse("#0123456")); 534 assert(doesntParse("#012345678")); 535 536 // rgb() and rgba() 537 assert(parseHTMLColor(" rgba( 14.01, 25.0e+0%, 16, 0.5) ") == [14, 64, 16, 128]); 538 assert(parseHTMLColor("rgb(10e3,112,-3.4e-2)") == [255, 112, 0, 255]); 539 540 // hsl() and hsla() 541 assert(parseHTMLColor("hsl(0 , 100%, 50%)") == [255, 0, 0, 255]); 542 assert(parseHTMLColor("hsl(720, 100%, 50%)") == [255, 0, 0, 255]); 543 assert(parseHTMLColor("hsl(180deg, 100%, 50%)") == [0, 255, 255, 255]); 544 assert(parseHTMLColor("hsl(0grad, 100%, 50%)") == [255, 0, 0, 255]); 545 assert(parseHTMLColor("hsl(0rad, 100%, 50%)") == [255, 0, 0, 255]); 546 assert(parseHTMLColor("hsl(0turn, 100%, 50%)") == [255, 0, 0, 255]); 547 assert(parseHTMLColor("hsl(120deg, 100%, 50%)") == [0, 255, 0, 255]); 548 assert(parseHTMLColor("hsl(123deg, 2.5%, 0%)") == [0, 0, 0, 255]); 549 assert(parseHTMLColor("hsl(5.4e-5rad, 25%, 100%)") == [255, 255, 255, 255]); 550 assert(parseHTMLColor("hsla(0turn, 100%, 50%, 0.25)") == [255, 0, 0, 64]); 551 552 // gray values 553 assert(parseHTMLColor(" gray( +0.0% )") == [0, 0, 0, 255]); 554 assert(parseHTMLColor(" gray ") == [128, 128, 128, 255]); 555 assert(parseHTMLColor(" gray( 100%, 50% ) ") == [255, 255, 255, 128]); 556 557 // Named colors 558 assert(parseHTMLColor("tRaNsPaREnt") == [0, 0, 0, 0]); 559 assert(parseHTMLColor(" navy ") == [0, 0, 128, 255]); 560 assert(parseHTMLColor("lightgoldenrodyellow") == [250, 250, 210, 255]); 561 assert(doesntParse("animaginarycolorname")); // unknown named color 562 assert(doesntParse("navyblahblah")); // too much chars 563 assert(doesntParse("blac")); // incomplete color 564 assert(parseHTMLColor("lime") == [0, 255, 0, 255]); // termination with 2 candidate alive 565 assert(parseHTMLColor("limegreen") == [50, 205, 50, 255]); 566 }