1 module dlangui.core.cssparser;
2 
3 import std.traits;
4 import std.conv : to;
5 import std.string;
6 import std.array : empty;
7 import std.algorithm : equal;
8 import std.ascii : isAlpha, isWhite;
9 
10 import dlangui.core.dom;
11 import dlangui.core.css;
12 import dlangui.core.types : parseHexDigit;
13 
14 /// skip specified count of chars of string, returns next available character, or 0 if end of string reached
15 private char skip(ref string src, int count = 1) {
16     if (count >= src.length) {
17         src = null;
18         return 0;
19     }
20     src = src[count .. $];
21     return src[0];
22 }
23 
24 /// returns char of string at specified position (first by default) or 0 if end of string reached
25 private char peek(string str, int offset = 0) {
26     return offset >= str.length ? 0 : str[offset];
27 }
28 
29 /// skip spaces, move to new location, return first character in string, 0 if end of string reached
30 private char skipSpaces(ref string str)
31 {
32     string oldpos = str;
33     for (;;) {
34         char ch = str.peek;
35         if (!ch)
36             return 0;
37         while (isWhite(ch))
38             ch = str.skip;
39         if (str.peek == '/' && str.peek(1) == '*') {
40             // comment found
41             str.skip(2);
42             while (str.peek && (str.peek(0) != '*' || str.peek(1) != '/'))
43                 str.skip;
44             if (str.peek == '*' && str.peek(1) == '/' )
45                 str.skip(2);
46         }
47         ch = str.peek;
48         while (isWhite(ch))
49             ch = str.skip;
50         if (oldpos.ptr is str.ptr)
51             break;
52         if (str.empty)
53             return 0;
54         oldpos = str;
55     }
56     return str.peek;
57 }
58 
59 
60 private bool isIdentChar(char ch) {
61     return isAlpha(ch) || (ch == '-') || (ch == '_');
62 }
63 
64 /// parse css identifier
65 private string parseIdent(ref string src) {
66     int pos = 0;
67     for ( ; pos < src.length; pos++) {
68         if (!src[pos].isIdentChar)
69             break;
70     }
71     if (!pos)
72         return null;
73     string res = src[0 .. pos];
74     src.skip(pos);
75     src.skipSpaces;
76     return res;
77 }
78 
79 private bool skipChar(ref string src, char ch) {
80     src.skipSpaces;
81     if (src.peek == ch) {
82         src.skip;
83         src.skipSpaces;
84         return true;
85     }
86     return false;
87 }
88 
89 private string replaceChar(string s, char from, char to) {
90     foreach(ch; s) {
91         if (ch == from) {
92             char[] buf;
93             foreach(c; s)
94                 if (c == from)
95                     buf ~= to;
96                 else
97                     buf ~= c;
98             return buf.dup;
99         }
100     }
101     return s;
102 }
103 
104 /// remove trailing _ from string, e.g. "body_" -> "body"
105 private string removeTrailingUnderscore(string s) {
106     if (s.endsWith("_"))
107         return s[0..$-1];
108     return s;
109 }
110 
111 private int parseEnumItem(E)(ref string src, int defValue = -1) if (is(E == enum)) {
112     string ident = replaceChar(parseIdent(src), '-', '_');
113     foreach(member; EnumMembers!E) {
114         if (ident == removeTrailingUnderscore(member.to!string)) {
115             return member.to!int;
116         }
117     }
118     return defValue;
119 }
120 
121 private CssDeclType parseCssDeclType(ref string src) {
122     int n = parseEnumItem!CssDeclType(src, -1);
123     if (n < 0)
124         return CssDeclType.unknown;
125     if (!skipChar(src, ':')) // no : after identifier
126         return CssDeclType.unknown;
127     return cast(CssDeclType)n;
128 }
129 
130 private bool nextProperty(ref string str) {
131     int pos = 0;
132     for (; pos < str.length; pos++) {
133         char ch = str[pos];
134         if (ch == '}')
135             break;
136         if (ch == ';') {
137             pos++;
138             break;
139         }
140     }
141     str.skip(pos);
142     str.skipSpaces;
143     return !str.empty && str[0] != '}';
144 }
145 
146 
147 private int parseStandardColor(string ident) {
148     switch(ident) {
149         case "black": return 0x000000;
150         case "green": return 0x008000;
151         case "silver": return 0xC0C0C0;
152         case "lime": return 0x00FF00;
153         case "gray": return 0x808080;
154         case "olive": return 0x808000;
155         case "white": return 0xFFFFFF;
156         case "yellow": return 0xFFFF00;
157         case "maroon": return 0x800000;
158         case "navy": return 0x000080;
159         case "red": return 0xFF0000;
160         case "blue": return 0x0000FF;
161         case "purple": return 0x800080;
162         case "teal": return 0x008080;
163         case "fuchsia": return 0xFF00FF;
164         case "aqua": return 0x00FFFF;
165         default: return -1;
166     }
167 }
168 
169 private bool parseColor(ref string src, ref CssValue value)
170 {
171     value.type = CssValueType.unspecified;
172     value.value = 0;
173     if (!src.skipSpaces)
174         return false;
175     string ident = parseIdent(src);
176     if (!ident.empty) {
177         switch(ident) {
178             case "inherited":
179                 value.type = CssValueType.inherited;
180                 return true;
181             case "none":
182                 return true;
183             default:
184                 int v = parseStandardColor(ident);
185                 if (v >= 0) {
186                     value.value = v;
187                     value.type = CssValueType.color;
188                     return true;
189                 }
190                 return false;
191         }
192     }
193     char ch = src[0];
194     if (ch == '#') {
195         // #rgb or #rrggbb colors
196         ch = src.skip;
197         int nDigits = 0;
198         for ( ; nDigits < src.length && parseHexDigit(src[nDigits]) != uint.max; nDigits++ ) {
199         }
200         if ( nDigits==3 ) {
201             int r = parseHexDigit( src[0] );
202             int g = parseHexDigit( src[1] );
203             int b = parseHexDigit( src[2] );
204             value.type = CssValueType.color;
205             value.value = (((r + r*16) * 256) | (g + g*16)) * 256 | (b + b*16);
206             src.skip(3);
207             return true;
208         } else if ( nDigits==6 ) {
209             int r = parseHexDigit( src[0] ) * 16;
210             r += parseHexDigit( src[1] );
211             int g = parseHexDigit( src[2] ) * 16;
212             g += parseHexDigit( src[3] );
213             int b = parseHexDigit( src[4] ) * 16;
214             b += parseHexDigit( src[5] );
215             value.type = CssValueType.color;
216             value.value = ((r * 256) | g) * 256 | b;
217             src.skip(6);
218             return true;
219         }
220     }
221     return false;
222 }
223 
224 private bool parseLength(ref string src, ref CssValue value)
225 {
226     value.type = CssValueType.unspecified;
227     value.value = 0;
228     src.skipSpaces;
229     string ident = parseIdent(src);
230     if (!ident.empty) {
231         switch(ident) {
232             case "inherited":
233                 value.type = CssValueType.inherited;
234                 return true;
235             default:
236                 return false;
237         }
238     }
239     if (src.empty)
240         return false;
241     int n = 0;
242     char ch = src[0];
243     if (ch != '.') {
244         if (ch < '0' || ch > '9') {
245             return false; // not a number
246         }
247         while (ch >= '0' && ch <= '9') {
248             n = n*10 + (ch - '0');
249             ch = src.skip;
250             if (!ch)
251                 break;
252         }
253     }
254     int frac = 0;
255     int frac_div = 1;
256     if (ch == '.') {
257         src.skip;
258         if (!src.empty) {
259             ch = src[0];
260             while (ch >= '0' && ch <= '9') {
261                 frac = frac*10 + (ch - '0');
262                 frac_div *= 10;
263                 ch = src.skip;
264                 if (!ch)
265                     break;
266             }
267         }
268     }
269     if (ch == '%') {
270         value.type = CssValueType.percent;
271         src.skip;
272     } else {
273         ident = parseIdent(src);
274         if (!ident.empty) {
275             switch(ident) {
276                 case "em": 
277                 case "m": // for DML - cannot add suffix which starts from 'e'
278                     value.type = CssValueType.em; break;
279                 case "pt": value.type = CssValueType.pt; break;
280                 case "ex": value.type = CssValueType.ex; break;
281                 case "px": value.type = CssValueType.px; break;
282                 case "in": value.type = CssValueType.in_; break;
283                 case "cm": value.type = CssValueType.cm; break;
284                 case "mm": value.type = CssValueType.mm; break;
285                 case "pc": value.type = CssValueType.pc; break;
286                 default:
287                     return false;
288             }
289         } else {
290             value.type = CssValueType.px;
291         }
292     }
293     if ( value.type == CssValueType.px || value.type == CssValueType.percent )
294         value.value = n;                               // normal
295     else
296         value.value = n * 256 + 256 * frac / frac_div; // *256
297     return true;
298 }
299 
300 private void appendItem(ref string[] list, ref char[] item) {
301     if (!item.empty) {
302         list ~= item.dup;
303         item.length = 0;
304     }
305 }
306 
307 /// splits string like "Arial", Times New Roman, Courier;  into list, stops on ; and }
308 /// returns true if at least one item added to list; moves str to new position
309 bool splitPropertyValueList(ref string str, ref string[] list)
310 {
311     int i=0;
312     char quote_char = 0;
313     char[] name;
314     bool last_space = false;
315     for (i=0; i < str.length; i++) {
316         char ch = str[i];
317         switch(ch) {
318             case '\'':
319             case '\"':
320                 if (quote_char == 0) {
321                     if (!name.empty)
322                         appendItem(list, name);
323                     quote_char = ch;
324                 } else if (quote_char == ch) {
325                     if (!name.empty)
326                         appendItem(list, name);
327                     quote_char = 0;
328                 } else {
329                     // append char
330                     name ~= ch;
331                 }
332                 last_space = false;
333                 break;
334             case ',':
335                 {
336                     if (quote_char==0) {
337                         if (!name.empty)
338                             appendItem(list, name);
339                     } else {
340                         // inside quotation: append char
341                         name ~= ch;
342                     }
343                     last_space = false;
344                 }
345                 break;
346             case '\t':
347             case ' ':
348                 {
349                     if (quote_char != 0)
350                         name ~= ch;
351                     last_space = true;
352                 }
353                 break;
354             case ';':
355             case '}':
356                 if (quote_char==0) {
357                     if (!name.empty)
358                         appendItem(list, name);
359                     str = i < str.length ? str[i .. $] : null;
360                     return list.length > 0;
361                 } else {
362                     // inside quotation: append char
363                     name ~= ch;
364                     last_space = false;
365                 }
366                 break;
367             default:
368                 if (last_space && !name.empty && quote_char == 0)
369                     name ~= ' ';
370                 name ~= ch;
371                 last_space = false;
372                 break;
373         }
374     }
375     if (!name.empty)
376         appendItem(list, name);
377     str = i < str.length ? str[i .. $] : null;
378     return list.length > 0;
379 }
380 
381 unittest {
382     string src = "Arial, 'Times New Roman', \"Arial Black\", sans-serif; next-property: }";
383     string[] list;
384     assert(splitPropertyValueList(src, list));
385     assert(list.length == 4);
386     assert(list[0] == "Arial");
387     assert(list[1] == "Times New Roman");
388     assert(list[2] == "Arial Black");
389     assert(list[3] == "sans-serif");
390 }
391 
392 /// joins list of items into comma separated string, each item in quotation marks
393 string joinPropertyValueList(string[] list) {
394     if (list.empty)
395         return null;
396     char[] res;
397 
398     for (int i = 0; i < list.length; i++) {
399         if (i > 0)
400             res ~= ", ";
401         res ~= "\"";
402         res ~= list[i];
403         res ~= "\"";
404     }
405 
406     return res.dup;
407 }
408 
409 unittest {
410     assert(joinPropertyValueList(["item1", "item 2"]) == "\"item1\", \"item 2\"");
411 }
412 
413 
414 private bool parseAttrValue(ref string str, ref string attrvalue)
415 {
416     char[] buf;
417     int pos = 0;
418     if (!str.skipSpaces)
419         return false;
420     char ch = str[0];
421     if (ch == '\"') {
422         str.skip;
423         for ( ; pos < str.length && str[pos] != '\"'; pos++) {
424             if (pos >= 1000)
425                 return false;
426         }
427         if (pos >= str.length || str[pos] != '\"')
428             return false;
429         buf ~= str[0 .. pos];
430         str.skip(pos + 1);
431         if (!str.skipSpaces)
432             return false;
433         if (str[0] != ']')
434             return false;
435         str.skip;
436         attrvalue = buf.dup;
437         return true;
438     } else {
439         for ( ; pos < str.length && str[pos] != ' ' && str[pos] != '\t' && str[pos] != ']'; pos++) {
440             if (pos >= 1000)
441                 return false;
442         }
443         if (pos >= str.length || str[pos] != ']')
444             return false;
445         buf ~= str[0 .. pos];
446         str.skip(pos + 1);
447         attrvalue = buf.dup;
448         return true;
449     }
450 }
451 
452 private CssSelectorRule parseAttr(ref string str, Document doc)
453 {
454     CssSelectorRuleType st = CssSelectorRuleType.universal;
455     char ch = str[0];
456     if (ch == '.') {
457         // E.class
458         str.skip;
459         str.skipSpaces;
460         string attrvalue = parseIdent(str);
461         if (attrvalue.empty)
462             return null;
463         CssSelectorRule rule = new CssSelectorRule(CssSelectorRuleType.class_);
464         rule.setAttr(Attr.class_, attrvalue.toLower);
465         return rule;
466     } else if (ch == '#') {
467         // E#id
468         str.skip;
469         str.skipSpaces;
470         string attrvalue = parseIdent(str);
471         if (attrvalue.empty)
472             return null;
473         CssSelectorRule rule = new CssSelectorRule(CssSelectorRuleType.id);
474         rule.setAttr(Attr.id, attrvalue.toLower);
475         return rule;
476     } else if (ch != '[')
477         return null;
478     // [.....] rule
479     str.skip; // skip [
480     str.skipSpaces;
481     string attrname = parseIdent(str);
482     if (attrname.empty)
483         return null;
484     if (!str.skipSpaces)
485         return null;
486     string attrvalue = null;
487     ch = str[0];
488     if (ch == ']') {
489         // empty []
490         st = CssSelectorRuleType.attrset;
491         str.skip; // skip ]
492     } else if (ch == '=') {
493         str.skip; // skip =
494         if (!parseAttrValue(str, attrvalue))
495             return null;
496         st = CssSelectorRuleType.attreq;
497     } else if (ch == '~' && str.length > 1 && str[1] == '=') {
498         str.skip(2); // skip ~=
499         if (!parseAttrValue(str, attrvalue))
500             return null;
501         st = CssSelectorRuleType.attrhas;
502     } else if (ch == '|' && str.length > 1 && str[1] == '=') {
503         str.skip(2); // skip |=
504         if (!parseAttrValue(str, attrvalue))
505             return null;
506         st = CssSelectorRuleType.attrstarts;
507     } else {
508         return null;
509     }
510     CssSelectorRule rule = new CssSelectorRule(st);
511     attr_id id = doc.attrId(attrname);
512     rule.setAttr(id, attrvalue);
513     return rule;
514 }
515 
516 /// Parse css properties declaration either in {} or w/o {} - e.g.  { width: 40%; margin-top: 3px } -- returns null if parse error occured or property list is empty
517 CssDeclaration parseCssDeclaration(ref string src, bool mustBeInBrackets = true) {
518     if (!src.skipSpaces)
519         return null;
520     if (mustBeInBrackets && !skipChar(src, '{'))
521         return null; // decl must start with {
522     CssDeclaration res = new CssDeclaration();
523     for (;;) {
524         CssDeclType propId = parseCssDeclType(src);
525         if (src.empty)
526             break;
527         if (propId != CssDeclType.unknown) {
528             int n = -1;
529             string s = null;
530             switch(propId) with(CssDeclType) {
531                 case display: n = parseEnumItem!CssDisplay(src, -1); break;
532                 case white_space: n = parseEnumItem!CssWhiteSpace(src, -1); break;
533                 case text_align: n = parseEnumItem!CssTextAlign(src, -1); break;
534                 case text_align_last: n = parseEnumItem!CssTextAlign(src, -1); break;
535                 case text_decoration: n = parseEnumItem!CssTextDecoration(src, -1); break;
536                 case hyphenate:
537                 case _webkit_hyphens: // -webkit-hyphens
538                 case adobe_hyphenate: // adobe-hyphenate
539                 case adobe_text_layout: // adobe-text-layout
540                     n = parseEnumItem!CssHyphenate(src, -1); 
541                     break; // hyphenate
542                 case color:
543                 case background_color:
544                     CssValue v;
545                     if (parseColor(src, v)) {
546                         res.addLengthDecl(propId, v);
547                     }
548                     break;
549                 case vertical_align: n = parseEnumItem!CssVerticalAlign(src, -1); break;
550                 case font_family: // id families like serif, sans-serif
551                     string[] list;
552                     string[] faceList;
553                     if (splitPropertyValueList(src, list)) {
554                         foreach(item; list) {
555                             string name = item;
556                             int family = parseEnumItem!CssFontFamily(name, -1); 
557                             if (family != -1) {
558                                 // family name, e.g. sans-serif
559                                 n = family;
560                             } else {
561                                 faceList ~= item;
562                             }
563                         }
564                     }
565                     s = joinPropertyValueList(faceList);
566                     break; 
567                 case font_style: n = parseEnumItem!CssFontStyle(src, -1); break;
568                 case font_weight:
569                     n = parseEnumItem!CssFontWeight(src, -1);
570                     if (n < 0) {
571                         CssValue value;
572                         if (parseLength(src, value)) {
573                             if (value.type == CssValueType.px) {
574                                 if (value.value < 150)
575                                     n = CssFontWeight.fw_100;
576                                 else if (value.value < 250)
577                                     n = CssFontWeight.fw_200;
578                                 else if (value.value < 350)
579                                     n = CssFontWeight.fw_300;
580                                 else if (value.value < 450)
581                                     n = CssFontWeight.fw_400;
582                                 else if (value.value < 550)
583                                     n = CssFontWeight.fw_500;
584                                 else if (value.value < 650)
585                                     n = CssFontWeight.fw_600;
586                                 else if (value.value < 750)
587                                     n = CssFontWeight.fw_700;
588                                 else if (value.value < 850)
589                                     n = CssFontWeight.fw_800;
590                                 else
591                                     n = CssFontWeight.fw_900;
592                             }
593                         }
594                     }
595 
596                     //n = parseEnumItem!Css(src, -1); 
597                     break;
598                 case text_indent: 
599                     {
600                         // read length
601                         CssValue len;
602                         bool negative = false;
603                         if (src[0] == '-') {
604                             src.skip;
605                             negative = true;
606                         }
607                         if (parseLength(src, len)) {
608                             // read optional "hanging" flag
609                             src.skipSpaces;
610                             string attr = parseIdent(src);
611                             if (attr == "hanging")
612                                 len.value = -len.value;
613                             res.addLengthDecl(propId, len);
614                         }
615                     }
616                     break;
617                 case line_height:
618                 case letter_spacing:
619                 case font_size:
620                 case width:
621                 case height:
622                 case margin_left:
623                 case margin_right:
624                 case margin_top:
625                 case margin_bottom:
626                 case padding_left:
627                 case padding_right:
628                 case padding_top:
629                 case padding_bottom:
630                     // parse length
631                     CssValue value;
632                     if (parseLength(src, value))
633                         res.addLengthDecl(propId, value);
634                     break;
635                 case margin: 
636                 case padding: 
637                     //n = parseEnumItem!Css(src, -1); 
638                     CssValue[4] len;
639                     int i;
640                     for (i = 0; i < 4; ++i)
641                         if (!parseLength(src, len[i]))
642                             break;
643                     if (i) {
644                         switch (i) {
645                             case 1: 
646                                 len[1] = len[0];
647                                 goto case; /* fall through */
648                             case 2: 
649                                 len[2] = len[0];
650                                 goto case; /* fall through */
651                             case 3: 
652                                 len[3] = len[1];
653                                 break;
654                             default:
655                                 break;
656                         }
657                         if (propId == margin) {
658                             res.addLengthDecl(margin_left, len[0]);
659                             res.addLengthDecl(margin_top, len[1]);
660                             res.addLengthDecl(margin_right, len[2]);
661                             res.addLengthDecl(margin_bottom, len[3]);
662                         } else {
663                             res.addLengthDecl(padding_left, len[0]);
664                             res.addLengthDecl(padding_top, len[1]);
665                             res.addLengthDecl(padding_right, len[2]);
666                             res.addLengthDecl(padding_bottom, len[3]);
667                         }
668                     }
669                     break;
670                 case page_break_before:
671                 case page_break_inside:
672                 case page_break_after:
673                     n = parseEnumItem!CssPageBreak(src, -1); 
674                     break;
675                 case list_style:
676                     //n = parseEnumItem!Css(src, -1); 
677                     break;
678                 case list_style_type: n = parseEnumItem!CssListStyleType(src, -1); break;
679                 case list_style_position: n = parseEnumItem!CssListStylePosition(src, -1); break;
680                 case list_style_image: 
681                     //n = parseEnumItem!CssListStyleImage(src, -1); 
682                     break;
683                 default:
684                     break;
685             }
686             if (n >= 0 || !s.empty)
687                 res.addDecl(propId, n, s);
688         }
689         if (!nextProperty(src))
690             break;
691     }
692     if (mustBeInBrackets && !skipChar(src, '}'))
693         return null;
694     if (res.empty)
695         return null;
696     return res;
697 }
698 
699 /// parse Css selector, return selector object if parsed ok, null if error occured
700 CssSelector parseCssSelector(ref string str, Document doc) {
701     if (str.empty)
702         return null;
703     CssSelector res = new CssSelector();
704     for (;;) {
705         if (!str.skipSpaces)
706             return null;
707         char ch = str[0];
708         string ident = parseIdent(str);
709         if (ch == '*') { // universal selector
710             str.skip;
711             str.skipSpaces;
712             res.id = 0;
713         }  else if (ch == '.') { // classname follows
714             res.id = 0;
715             // will be parsed as attribute
716         }  else if (!ident.empty) {
717             // ident
718             res.id = doc.tagId(ident);
719         } else {
720             return null;
721         }
722         if (!str.skipSpaces)
723             return null;
724         ch = str[0];
725         if (ch == ',' || ch == '{')
726             return res;
727         // one or more attribute rules
728         bool attr_rule = false;
729         while (ch == '[' || ch == '.' || ch == '#') {
730             CssSelectorRule rule = parseAttr(str, doc);
731             if (!rule)
732                 return null;
733             res.insertRuleStart(rule); //insertRuleAfterStart
734             ch = str.skipSpaces;
735             attr_rule = true;
736             //continue;
737         }
738         // element relation
739         if (ch == '>') {
740             str.skip;
741             CssSelectorRule rule = new CssSelectorRule(CssSelectorRuleType.parent);
742             rule.id = res.id;
743             res.insertRuleStart(rule);
744             res.id = 0;
745             continue;
746         } else if (ch == '+') {
747             str.skip;
748             CssSelectorRule rule = new CssSelectorRule(CssSelectorRuleType.predecessor);
749             rule.id = res.id;
750             res.insertRuleStart(rule);
751             res.id = 0;
752             continue;
753         } else if (ch.isAlpha) {
754             CssSelectorRule rule = new CssSelectorRule(CssSelectorRuleType.ancessor);
755             rule.id = res.id;
756             res.insertRuleStart(rule);
757             res.id = 0;
758             continue;
759         }
760         if (!attr_rule)
761             return null;
762         else if (str.length > 0 && (str[0] == ',' || str[0] == '{'))
763             return res;
764     }
765 }
766 
767 /// skips until } or end of string, returns true if some characters left in string
768 private bool skipUntilEndOfRule(ref string str)
769 {
770     while (str.length && str[0] != '}')
771         str.skip;
772     if (str.peek == '}')
773         str.skip;
774     return !str.empty;
775 }
776 
777 
778 unittest {
779     Document doc = new Document();
780     string str;
781     str = "body { width: 50% }";
782     assert(parseCssSelector(str, doc) !is null);
783     assert(parseCssDeclaration(str, true) !is null);
784     str = "body > p { font-family: sans-serif }";
785     assert(parseCssSelector(str, doc) !is null);
786     assert(parseCssDeclaration(str, true) !is null);
787     str = ".myclass + div { }";
788     assert(parseCssSelector(str, doc) !is null);
789     assert(parseCssDeclaration(str, true) is null); // empty property decl
790     destroy(doc);
791 }
792 
793 /// parse stylesheet text
794 bool parseStyleSheet(StyleSheet sheet, Document doc, string str) {
795     bool res = false;
796     for(;;) {
797         if (!str.skipSpaces)
798             break;
799         CssSelector[] selectors;
800         for(;;) {
801             CssSelector selector = parseCssSelector(str, doc);
802             if (!selector)
803                 break;
804             selectors ~= selector;
805             str.skipChar(',');
806         }
807         if (selectors.length) {
808             if (CssDeclaration decl = parseCssDeclaration(str, true)) {
809                 foreach(item; selectors) {
810                     item.setDeclaration(decl);
811                     sheet.add(item);
812                     res = true;
813                 }
814             }
815         }
816         if (!skipUntilEndOfRule(str))
817             break;
818     }
819     return res;
820 }
821 
822 unittest {
823     string src = q{
824         body { width: 50%; color: blue }
825         body > div, body > section {
826             /* some comment 
827                goes here */
828             font-family: serif;
829             background-color: yellow;
830         }
831         section {
832             margin-top: 5px
833         }
834     };
835     Document doc = new Document();
836     StyleSheet sheet = new StyleSheet();
837     assert(parseStyleSheet(sheet, doc, src));
838     assert(sheet.length == 2);
839     // check appending of additional source text
840     assert(parseStyleSheet(sheet, doc, "pre { white-space: pre }"));
841     assert(sheet.length == 3);
842     destroy(doc);
843 }
844 
845 unittest {
846     Document doc = new Document();
847     StyleSheet sheet = new StyleSheet();
848     assert(parseStyleSheet(sheet, doc, "* { color: #aaa }"));
849     assert(sheet.length == 1);
850     assert(parseStyleSheet(sheet, doc, "div, p { display: block }"));
851     assert(sheet.length == 3);
852     // check appending of additional source text
853     assert(parseStyleSheet(sheet, doc, "pre { white-space: pre }"));
854     assert(sheet.length == 4);
855     assert(parseStyleSheet(sheet, doc, "pre { font-size: 120% }"));
856     assert(sheet.length == 5);
857     destroy(doc);
858 }