Phi: nicer scroll animation for METAR widget
[fg:fgdata.git] / Nasal / canvas / svg.nas
1 # Parse an xml file into a canvas group element
2 #
3 # @param group    The canvas.Group instance to append the parsed elements to
4 # @param path     The path of the svg file (absolute or relative to FG_ROOT)
5 # @param options  Optional hash of options
6 var parsesvg = func(group, path, options = nil)
7 {
8   if( !isa(group, Group) )
9     die("Invalid argument group (type != Group)");
10
11   if( options == nil )
12     options = {};
13
14   if( typeof(options) != "hash" )
15     die("Options need to be of type hash!");
16
17   # resolve paths using standard SimGear logic
18   var file_path = resolvepath(path);
19   if (file_path == "")
20     die("File not found: "~path);
21   path = file_path;
22
23   var _printlog = printlog;
24   var printlog = func(level, msg)
25   {
26     _printlog(level, "parsesvg: " ~ msg ~ " [path='" ~ path ~ "']");
27   };
28
29   var custom_font_mapper = options['font-mapper'];
30   var font_mapper = func(family, weight, style)
31   {
32     if( typeof(custom_font_mapper) == 'func' )
33     {
34       var font = custom_font_mapper(family, weight, style);
35       if( font != nil )
36         return font;
37     }
38
39     if( string.match(family,"Liberation*") ) {
40       style = style == "italic" ? "Italic" : "";
41       weight = weight == "bold" ? "Bold" : "";
42
43       var s = weight ~ style;
44       if( s == "" ) s = "Regular";
45
46       return "LiberationFonts/" ~ string.replace(family," ", "") ~ "-" ~ s ~ ".ttf";
47     }
48
49
50     return "LiberationFonts/LiberationMono-Bold.ttf";
51   };
52
53   # Helper to get number without unit (eg. px)
54   var evalCSSNum = func(css_num)
55   {
56     if( css_num.ends_with("px") )
57       return substr(css_num, 0, size(css_num) - 2);
58     else if( css_num.ends_with("%") )
59       return substr(css_num, 0, size(css_num) - 1) / 100;
60
61     return css_num;
62   }
63
64   var level = 0;
65   var skip  = 0;
66   var stack = [group];
67   var close_stack = []; # helper for check tag closing
68
69   var defs_stack = [];
70
71   var text = nil;
72   var tspans = nil;
73
74   # lookup table for element ids (for <use> element)
75   var id_dict = {};
76
77   # lookup table for mask and clipPath element ids
78   var clip_dict = {};
79   var cur_clip = nil;
80
81   # ----------------------------------------------------------------------------
82   # Create a new child an push it onto the stack
83   var pushElement = func(type, id = nil)
84   {
85     append(stack, stack[-1].createChild(type, id));
86     append(close_stack, level);
87
88     if( typeof(id) == 'scalar' and size(id) )
89       id_dict[ id ] = stack[-1];
90
91     if( cur_clip != nil )
92     {
93       if(     cur_clip['x'] != nil
94           and cur_clip['y'] != nil
95           and cur_clip['width'] != nil
96           and cur_clip['height'] != nil )
97       {
98         var rect = sprintf(
99           "rect(%f, %f, %f, %f)",
100           cur_clip['y'],
101           cur_clip['x'] + cur_clip['width'],
102           cur_clip['y'] + cur_clip['height'],
103           cur_clip['x']
104         );
105
106         stack[-1].set("clip", rect);
107         stack[-1].set("clip-frame", canvas.Element.LOCAL);
108       }
109       else
110         printlog(
111           "warn",
112           "Invalid or unsupported clip for element '" ~ id ~ "'"
113         );
114
115       cur_clip = nil;
116     }
117   }
118
119   # ----------------------------------------------------------------------------
120   # Remove the topmost element from the stack
121   var popElement = func
122   {
123     stack[-1].updateCenter();
124     # Create rotation matrix after all SVG defined transformations
125     stack[-1].set("tf-rot-index", stack[-1].createTransform()._node.getIndex());
126
127     pop(stack);
128     pop(close_stack);
129   }
130
131   # ----------------------------------------------------------------------------
132   # Parse a transformation (matrix)
133   # http://www.w3.org/TR/SVG/coords.html#TransformAttribute
134   var parseTransform = func(tf)
135   {
136     if( tf == nil )
137       return;
138
139     var end = 0;
140     while(1)
141     {
142       var start_type = tf.find_first_not_of("\t\n ", end);
143       if( start_type < 0 )
144         break;
145
146       var end_type = tf.find_first_of("(\t\n ", start_type + 1);
147       if( end_type < 0 )
148         break;
149
150       var start_args = tf.find('(', end_type);
151       if( start_args < 0 )
152         break;
153
154       var values = [];
155       end = start_args + 1;
156       while(1)
157       {
158         var start_num = tf.find_first_not_of(",\t\n ", end);
159         if( start_num < 0 )
160           break;
161         if( tf[start_num] == `)` )
162           break;
163
164         end = tf.find_first_of("),\t\n ", start_num + 1);
165         if( end < 0 )
166           break;
167         append(values, substr(tf, start_num, end - start_num));
168       }
169
170       if( end > 0 )
171         end += 1;
172
173       var type = substr(tf, start_type, end_type - start_type);
174
175       # TODO should we warn if to much/wrong number of arguments given?
176       if( type == "translate" )
177       {
178         # translate(<tx> [<ty>]), which specifies a translation by tx and ty. If
179         # <ty> is not provided, it is assumed to be zero.
180         stack[-1].createTransform().setTranslation
181         (
182           values[0],
183           size(values) > 1 ? values[1] : 0,
184         );
185       }
186       else if( type == "scale" )
187       {
188         # scale(<sx> [<sy>]), which specifies a scale operation by sx and sy. If
189         # <sy> is not provided, it is assumed to be equal to <sx>.
190         stack[-1].createTransform().setScale(values);
191       }
192       else if( type == "rotate" )
193       {
194         # rotate(<rotate-angle> [<cx> <cy>]), which specifies a rotation by
195         # <rotate-angle> degrees about a given point.
196         stack[-1].createTransform().setRotation
197         (
198           values[0] * D2R, # internal functions use rad
199           size(values) > 1 ? values[1:] : nil
200         );
201       }
202       else if( type == "matrix" )
203       {
204         if( size(values) == 6 )
205           stack[-1].createTransform(values);
206         else
207           printlog(
208             "warn",
209             "Invalid arguments to matrix transform: " ~ debug.string(values, 0)
210           );
211       }
212       else
213         printlog("warn", "Unknown transform type: '" ~ type ~ "'");
214     }
215   };
216
217   # ----------------------------------------------------------------------------
218   # Parse a path
219   # http://www.w3.org/TR/SVG/paths.html#PathData
220
221   # map svg commands OpenVG commands
222   var cmd_map = {
223     z: Path.VG_CLOSE_PATH,
224     m: Path.VG_MOVE_TO,
225     l: Path.VG_LINE_TO,
226     h: Path.VG_HLINE_TO,
227     v: Path.VG_VLINE_TO,
228     q: Path.VG_QUAD_TO,
229     c: Path.VG_CUBIC_TO,
230     t: Path.VG_SQUAD_TO,
231     s: Path.VG_SCUBIC_TO
232   };
233
234   var parsePath = func(path_data)
235   {
236     if( path_data == nil )
237       return;
238
239     var pos = 0;
240     var cmds = [];
241     var coords = [];
242
243     while(1)
244     {
245       # skip trailing spaces
246       pos = path_data.find_first_not_of("\t\n ", pos);
247       if( pos < 0 )
248         break;
249
250       # get command
251       var cmd = substr(path_data, pos, 1);
252       pos += 1;
253
254       # and get all following arguments
255       var args = [];
256       while(1)
257       {
258         pos = path_data.find_first_not_of(",\t\n ", pos);
259         if( pos < 0 )
260           break;
261
262         var start_num = pos;
263         pos = path_data.find_first_not_of("e-.0123456789", start_num);
264         if( start_num == pos )
265           break;
266
267         append(args, substr( path_data,
268                              start_num,
269                              pos > 0 ? pos - start_num : nil ));
270       }
271
272       # now execute the command
273       var rel = string.islower(cmd[0]);
274       var cmd = string.lc(cmd);
275       if( cmd == 'a' )
276       {
277         for(var i = 0; i + 7 <= size(args); i += 7)
278         {
279           # SVG: (rx ry x-axis-rotation large-arc-flag sweep-flag x y)+
280           # OpenVG: rh,rv,rot,x0,y0
281           if( args[i + 3] )
282             var cmd_vg = args[i + 4] ? Path.VG_LCCWARC_TO : Path.VG_LCWARC_TO;
283           else
284             var cmd_vg = args[i + 4] ? Path.VG_SCCWARC_TO : Path.VG_SCWARC_TO;
285           append(cmds, rel ? cmd_vg + 1: cmd_vg);
286           append(coords, args[i],
287                          args[i + 1],
288                          args[i + 2],
289                          args[i + 5],
290                          args[i + 6] );
291         }
292
293         if( math.mod(size(args), 7) > 0 )
294           printlog(
295             "warn",
296             "Invalid number of coords for cmd 'a' "
297             ~ "(" ~ size(args) ~ " mod 7 != 0)"
298           );
299       }
300       else
301       {
302         var cmd_vg = cmd_map[cmd];
303         if( cmd_vg == nil )
304         {
305           printlog("warn", "command not found: '" ~ cmd ~ "'");
306           continue;
307         }
308
309         var num_coords = Path.num_coords[int(cmd_vg)];
310         if( num_coords == 0 )
311           append(cmds, cmd_vg);
312         else
313         {
314           for(var i = 0; i + num_coords <= size(args); i += num_coords)
315           {
316             append(cmds, rel ? cmd_vg + 1: cmd_vg);
317             for(var j = i; j < i + num_coords; j += 1)
318               append(coords, args[j]);
319
320             # If a moveto is followed by multiple pairs of coordinates, the
321             # subsequent pairs are treated as implicit lineto commands.
322             if( cmd == 'm' )
323               cmd_vg = cmd_map['l'];
324           }
325
326           if( math.mod(size(args), num_coords) > 0 )
327             printlog(
328               "warn",
329               "Invalid number of coords for cmd '" ~ cmd ~ "' "
330               ~ "(" ~ size(args) ~ " mod " ~ num_coords ~ " != 0)"
331             );
332         }
333       }
334     }
335
336     stack[-1].setData(cmds, coords);
337   };
338
339   # ----------------------------------------------------------------------------
340   # Parse text styles (and apply them to the topmost element)
341   var parseTextStyles = func(style)
342   {
343     # http://www.w3.org/TR/SVG/text.html#TextAnchorProperty
344     var h_align = style["text-anchor"];
345     if( h_align != nil )
346     {
347       if( h_align == "end" )
348         h_align = "right";
349       else if( h_align == "middle" )
350         h_align = "center";
351       else # "start"
352         h_align = "left";
353       stack[-1].set("alignment", h_align ~ "-baseline");
354     }
355     # TODO vertical align
356
357     var fill = style['fill'];
358     if( fill != nil )
359       stack[-1].set("fill", fill);
360
361     var font_family = style["font-family"];
362     var font_weight = style["font-weight"];
363     var font_style = style["font-style"];
364     if( font_family != nil or font_weight != nil or font_style != nil )
365       stack[-1].set("font", font_mapper(font_family, font_weight, font_style));
366
367     var font_size = style["font-size"];
368     if( font_size != nil )
369       stack[-1].setDouble("character-size", evalCSSNum(font_size));
370
371     var line_height = style["line-height"];
372     if( line_height != nil )
373       stack[-1].setDouble("line-height", evalCSSNum(line_height));
374   }
375
376   # ----------------------------------------------------------------------------
377   # Parse a css style attribute
378   var parseStyle = func(style)
379   {
380     if( style == nil )
381       return {};
382
383     var styles = {};
384     foreach(var part; split(';', style))
385     {
386       if( !size(part = string.trim(part)) )
387         continue;
388       if( size(part = split(':',part)) != 2 )
389         continue;
390
391       var key = string.trim(part[0]);
392       if( !size(key) )
393         continue;
394
395       var value = string.trim(part[1]);
396       if( !size(value) )
397         continue;
398
399       styles[key] = value;
400     }
401
402     return styles;
403   }
404
405   # ----------------------------------------------------------------------------
406   # Parse a css color
407   var parseColor = func(s)
408   {
409     var color = [0, 0, 0];
410     if( s == nil )
411       return color;
412
413     if( size(s) == 7 and substr(s, 0, 1) == '#' )
414     {
415       return [ std.stoul(substr(s, 1, 2), 16) / 255,
416                 std.stoul(substr(s, 3, 2), 16) / 255,
417                 std.stoul(substr(s, 5, 2), 16) / 255 ];
418     }
419
420     return color;
421   };
422
423   # ----------------------------------------------------------------------------
424   # XML parsers element open callback
425   var start = func(name, attr)
426   {
427     level += 1;
428
429     if( skip )
430       return;
431
432     if( level == 1 )
433     {
434       if( name != 'svg' )
435         die("Not an svg file (root=" ~ name ~ ")");
436       else
437         return;
438     }
439
440     if( size(defs_stack) > 0 )
441     {
442       if( name == "mask" or name == "clipPath" )
443       {
444         append(defs_stack, {'type': name, 'id': attr['id']});
445       }
446       else if( name == "rect" )
447       {
448         foreach(var p; ["x", "y", "width", "height"])
449           defs_stack[-1][p] = evalCSSNum(attr[p]);
450         skip = level;
451       }
452       else
453       {
454         printlog("info", "Skipping unknown element in <defs>: <" ~ name ~ ">");
455         skip = level;
456       }
457       return;
458     }
459
460     var style = parseStyle(attr['style']);
461
462     var clip_id = attr['clip-path'] or attr['mask'];
463     if( clip_id != nil and clip_id != "none" )
464     {
465       if(     clip_id.starts_with("url(#")
466           and clip_id[-1] == `)` )
467         clip_id = substr(clip_id, 5, size(clip_id) - 5 - 1);
468
469       cur_clip = clip_dict[clip_id];
470       if( cur_clip == nil )
471         printlog("warn", "Clip not found: '" ~ clip_id ~ "'");
472     }
473
474     if( style['display'] == 'none' )
475     {
476       skip = level;
477       return;
478     }
479     else if( name == "g" )
480     {
481       pushElement('group', attr['id']);
482     }
483     else if( name == "text" )
484     {
485       text = {
486         "attr": attr,
487         "style": style,
488         "text": ""
489       };
490       tspans = [];
491       return;
492     }
493     else if( name == "tspan" )
494     {
495       append(tspans, {
496         "attr": attr,
497         "style": style,
498         "text": ""
499       });
500       return;
501     }
502     else if( name == "path" or name == "rect" )
503     {
504       pushElement('path', attr['id']);
505
506       if( name == "rect" )
507       {
508         var width = evalCSSNum(attr['width']);
509         var height = evalCSSNum(attr['height']);
510         var x = evalCSSNum(attr['x']);
511         var y = evalCSSNum(attr['y']);
512         var rx = attr['rx'];
513         var ry = attr['ry'];
514
515         if( ry == nil )
516           ry = rx;
517         else if( rx == nil )
518           rx = ry;
519
520         var cfg = {};
521         if( rx != nil )
522           cfg["border-radius"] = [evalCSSNum(rx), evalCSSNum(ry)];
523
524         stack[-1].rect(x, y, width, height, cfg);
525       }
526       else
527         parsePath(attr['d']);
528
529       stack[-1].set('fill', style['fill']);
530
531       var w = style['stroke-width'];
532       stack[-1].setStrokeLineWidth( w != nil ? evalCSSNum(w) : 1 );
533       stack[-1].set('stroke', style['stroke'] or "none");
534
535       var linecap = style['stroke-linecap'];
536       if( linecap != nil )
537         stack[-1].setStrokeLineCap(style['stroke-linecap']);
538
539       var linejoin = style['stroke-linejoin'];
540       if( linejoin != nil )
541         stack[-1].setStrokeLineJoin(style['stroke-linejoin']);
542
543
544       # http://www.w3.org/TR/SVG/painting.html#StrokeDasharrayProperty
545       var dash = style['stroke-dasharray'];
546       if( dash and size(dash) > 3 )
547         # at least 2 comma separated values...
548         stack[-1].setStrokeDashArray(split(',', dash));
549     }
550     else if( name == "use" )
551     {
552       var ref = attr["xlink:href"];
553       if( ref == nil or size(ref) < 2 or ref[0] != `#` )
554         return printlog("warn", "Invalid or missing href: '" ~ ref ~ '"');
555
556       var el_src = id_dict[ substr(ref, 1) ];
557       if( el_src == nil )
558         return printlog("warn", "Reference to unknown element '" ~ ref ~ "'");
559
560       # Create new element and copy sub branch from source node
561       pushElement(el_src._node.getName(), attr['id']);
562       props.copy(el_src._node, stack[-1]._node);
563
564       # copying also overrides the id so we need to set it again
565       stack[-1]._node.getNode("id").setValue(attr['id']);
566     }
567     else if( name == "defs" )
568     {
569       append(defs_stack, "defs");
570       return;
571     }
572     else
573     {
574       printlog("info", "Skipping unknown element '" ~ name ~ "'");
575       skip = level;
576       return;
577     }
578
579     parseTransform(attr['transform']);
580
581     var cx = attr['inkscape:transform-center-x'];
582     if( cx != nil and cx != 0 )
583       stack[-1].setDouble("center-offset-x", evalCSSNum(cx));
584
585     var cy = attr['inkscape:transform-center-y'];
586     if( cy != nil and cy != 0 )
587       stack[-1].setDouble("center-offset-y", -evalCSSNum(cy));
588   };
589
590   # XML parsers element close callback
591   var end = func(name)
592   {
593     level -= 1;
594
595     if( skip )
596     {
597       if( level < skip )
598         skip = 0;
599       return;
600     }
601
602     if( size(defs_stack) > 0 )
603     {
604       if( name != "defs" )
605       {
606         var type = defs_stack[-1]['type'];
607         if( type == "mask" or type == "clipPath" )
608           clip_dict[defs_stack[-1]['id']] = defs_stack[-1];
609       }
610
611       pop(defs_stack);
612       return;
613     }
614
615     if( size(close_stack) and (level + 1) == close_stack[-1] )
616       popElement();
617
618     if( name == "text" )
619     {
620       # Inkscape/SVG text is a bit complicated. If we only got a single tspan
621       # or text without tspan we create just a single canvas.Text, otherwise
622       # we create a canvas.Group with a canvas.Text as child for each tspan.
623       # We need to take care to apply the transform attribute of the text
624       # element to the correct canvas element, and also correctly inherit
625       # the style properties.
626       var character_size = 24;
627       if( size(tspans) > 1 )
628       {
629         pushElement('group', text.attr['id']);
630         parseTextStyles(text.style);
631         parseTransform(text.attr['transform']);
632
633         character_size = stack[-1].get("character-size", character_size);
634       }
635
636       # Helper for getting first number in space separated list of numbers.
637       var first_num = func(str)
638       {
639         if( str == nil )
640           return 0;
641         var end = str.find_first_of(" \n\t");
642         if( end < 0 )
643           return str;
644         else
645           return substr(str, 0, end);
646       }
647
648       var line = 0;
649       foreach(var tspan; tspans)
650       {
651         # Always take first number and ignore individual character placment
652         var x = first_num(tspan.attr['x'] or text.attr['x']);
653         var y = first_num(tspan.attr['y'] or text.attr['y']);
654
655         # Sometimes Inkscape forgets writing x and y coordinates and instead
656         # just indicates a multiline text with sodipodi:role="line".
657         if( tspan.attr['y'] == nil and tspan.attr['sodipodi:role'] == "line" )
658           # TODO should we combine multiple lines into a single text separated
659           #      with newline characters?
660           y += line
661              * stack[-1].get("line-height", 1.25)
662              * stack[-1].get("character-size", character_size);
663
664         # Use id of text element with single tspan child, fall back to id of
665         # tspan if text has no id.
666         var id = text.attr['id'];
667         if( id == nil or size(tspans) > 1 )
668           id = tspan.attr['id'];
669
670         pushElement('text', id);
671         stack[-1].set("text", tspan.text);
672
673         if( x != 0 or y != 0 )
674           stack[-1].setTranslation(x, y);
675
676         if( size(tspans) == 1 )
677         {
678           parseTextStyles(text.style);
679           parseTransform(text.attr['transform']);
680         }
681
682         parseTextStyles(tspan.style);
683         popElement();
684
685         line += 1;
686       }
687
688       if( size(tspans) > 1 )
689         popElement();
690
691       text = nil;
692       tspans = nil;
693     }
694   };
695
696   # XML parsers element data callback
697   var data = func(data)
698   {
699     if( skip )
700       return;
701
702     if( size(data) and tspans != nil )
703     {
704       if( size(tspans) == 0 )
705         # If no tspan is found use text element itself
706         append(tspans, text);
707
708       # If text contains xml entities it gets split at each entity. So let's
709       # glue it back into a single text...
710       tspans[-1]["text"] ~= data;
711     }
712   };
713
714   call(func parsexml(path, start, end, data), nil, var err = []);
715   if( size(err) )
716   {
717     var msg = err[0];
718     for(var i = 1; i + 1 < size(err); i += 2)
719     {
720       # err = ['error message', 'file', line]
721       msg ~= (i == 1 ? "\n  at " : "\n  called from: ")
722            ~ err[i] ~ ", line " ~ err[i + 1]
723     }
724     printlog("alert", msg ~ "\n ");
725
726     return 0;
727   }
728
729   return 1;
730 }