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 }