Canvas: Improve API and SVG parser.
[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   var custom_font_mapper = options['font-mapper'];
18   var font_mapper = func(family, weight)
19   {
20     if( typeof(custom_font_mapper) == 'func' )
21     {
22       var font = custom_font_mapper(family, weight);
23       if( font != nil )
24         return font;
25     }
26       
27     return "LiberationFonts/LiberationMono-Bold.ttf";
28   };
29
30   var level = 0;
31   var skip  = 0;
32   var stack = [group];
33   var close_stack = []; # helper for check tag closing
34   
35   # lookup table for element ids (for <use> element)
36   var id_dict = {};
37   
38   # ----------------------------------------------------------------------------
39   # Create a new child an push it onto the stack
40   var pushElement = func(type, id = nil)
41   {
42     append(stack, stack[-1].createChild(type, id));
43     append(close_stack, level);
44
45     if( typeof(id) == 'scalar' and size(id) )
46       id_dict[ id ] = stack[-1];
47   };
48   
49   # ----------------------------------------------------------------------------
50   # Parse a transformation (matrix)
51   # http://www.w3.org/TR/SVG/coords.html#TransformAttribute
52   var parseTransform = func(tf)
53   {
54     if( tf == nil )
55       return;
56
57     tf = std.string.new(tf);
58     
59     var end = 0;
60     while(1)
61     {
62       var start_type = tf.find_first_not_of("\t\n ", end);
63       if( start_type < 0 )
64         break;
65
66       var end_type = tf.find_first_of("(\t\n ", start_type + 1);
67       if( end_type < 0 )
68         break;
69
70       var start_args = tf.find('(', end_type);
71       if( start_args < 0 )
72         break;
73
74       var values = [];
75       end = start_args;
76       while(1)
77       {
78         var start_num = tf.find_first_not_of(",\t\n ", end + 1);
79         if( start_num < 0 )
80           break;
81         if( tf[start_num] == ')' )
82           break;
83
84         end = tf.find_first_of("),\t\n ", start_num + 1);
85         if( end < 0 )
86           break;
87         append(values, tf.substr(start_num, end - start_num));
88       }
89       
90       var type = tf.substr(start_type, end_type - start_type);
91
92       if( type == "translate" )
93         # translate(<tx> [<ty>]), which specifies a translation by tx and ty. If
94         # <ty> is not provided, it is assumed to be zero.
95         stack[-1].createTransform().setTranslation
96         (
97           values[0],
98           size(values) > 1 ? values[1] : 0,
99         );
100       else if( type == "matrix" )
101       {
102         if( size(values) == 6 )
103           stack[-1].createTransform(values);
104         else
105           debug.dump('invalid transform', type, values);
106       }
107       else
108         debug.dump(['unknown transform', type, values]);
109     }
110   };
111   
112   # ----------------------------------------------------------------------------
113   # Parse a path
114   # http://www.w3.org/TR/SVG/paths.html#PathData
115   
116   # map svg commands OpenVG commands
117   var cmd_map = {
118     z: Path.VG_CLOSE_PATH,
119     m: Path.VG_MOVE_TO,
120     l: Path.VG_LINE_TO,
121     h: Path.VG_HLINE_TO,
122     v: Path.VG_VLINE_TO,
123     q: Path.VG_QUAD_TO,
124     c: Path.VG_CUBIC_TO,
125     t: Path.VG_SQUAD_TO,
126     s: Path.VG_SCUBIC_TO
127   };
128   
129   var parsePath = func(d)
130   {
131     if( d == nil )
132       return;
133
134     var path_data = std.string.new(d);
135     var pos = 0;
136     
137     var cmds = [];
138     var coords = [];
139
140     while(1)
141     {
142       # skip trailing spaces
143       pos = path_data.find_first_not_of("\t\n ", pos);
144       if( pos < 0 )
145         break;
146
147       # get command
148       var cmd = path_data.substr(pos, 1);
149       pos += 1;
150
151       # and get all following arguments
152       var args = [];
153       while(1)
154       {
155         pos = path_data.find_first_not_of(",\t\n ", pos);
156         if( pos < 0 )
157           break;
158
159         var start_num = pos;
160         pos = path_data.find_first_not_of("e-.0123456789", start_num);
161         if( start_num == pos )
162           break;
163
164         append(args, path_data.substr(start_num, pos > 0 ? pos - start_num : nil));
165       }
166       
167       # now execute the command
168       var rel = string.islower(cmd[0]);
169       var cmd = string.lc(cmd);
170       if( cmd == 'a' )
171       {
172         for(var i = 0; i + 7 <= size(args); i += 7)
173         {
174           # SVG: (rx ry x-axis-rotation large-arc-flag sweep-flag x y)+
175           # OpenVG: rh,rv,rot,x0,y0
176           if( args[i + 3] )
177             var cmd_vg = args[i + 4] ? Path.VG_LCCWARC_TO : Path.VG_LCWARC_TO;
178           else
179             var cmd_vg = args[i + 4] ? Path.VG_SCCWARC_TO : Path.VG_SCWARC_TO;
180           append(cmds, rel ? cmd_vg + 1: cmd_vg);
181           append(coords, args[i],
182                          args[i + 1],
183                          args[i + 2],
184                          args[i + 5],
185                          args[i + 6] );
186         }
187         
188         if( math.mod(size(args), 7) > 0 )
189           debug.dump('too much coords for cmd', cmd, args);
190       }
191       else
192       {
193         var cmd_vg = cmd_map[cmd];
194         if( cmd_vg == nil )
195         {
196           debug.dump('command not found', cmd, args);
197           continue;
198         }
199
200         var num_coords = Path.num_coords[int(cmd_vg)];
201         if( num_coords == 0 )
202           append(cmds, cmd_vg);
203         else
204         {
205           for(var i = 0; i + num_coords <= size(args); i += num_coords)
206           {
207             append(cmds, rel ? cmd_vg + 1: cmd_vg);
208             for(var j = i; j < i + num_coords; j += 1)
209               append(coords, args[j]);
210
211             # If a moveto is followed by multiple pairs of coordinates, the
212             # subsequent pairs are treated as implicit lineto commands.            
213             if( cmd == 'm' )
214               cmd_vg = cmd_map['l'];
215           }
216           
217           if( math.mod(size(args), num_coords) > 0 )
218             debug.warn('too much coords for cmd: ' ~ cmd);
219         }
220       }
221     }
222     
223     stack[-1].setData(cmds, coords);
224   };
225   
226   # ----------------------------------------------------------------------------
227   # Parse a css style attribute
228   var parseStyle = func(style)
229   {
230     if( style == nil )
231       return {};
232     
233     var styles = {};
234     foreach(var part; split(';', style))
235     {
236       if( !size(part = string.trim(part)) )
237         continue;
238       if( size(part = split(':',part)) != 2 )
239         continue;
240
241       var key = string.trim(part[0]);
242       if( !size(key) )
243         continue;
244       
245       var value = string.trim(part[1]);
246       if( !size(value) )
247         continue;
248         
249       styles[key] = value;
250     }
251     
252     return styles;
253   }
254   
255   # ----------------------------------------------------------------------------
256   # Parse a css color
257   var parseColor = func(s)
258   {
259     var color = [0, 0, 0];
260     if( s == nil )
261       return color;
262
263     if( size(s) == 7 and substr(s, 0, 1) == '#' )
264     {
265       return [ std.stoul(substr(s, 1, 2), 16) / 255,
266                 std.stoul(substr(s, 3, 2), 16) / 255,
267                 std.stoul(substr(s, 5, 2), 16) / 255 ];
268     }
269     
270     return color;
271   };
272
273   # ----------------------------------------------------------------------------
274   # XML parsers element open callback
275   var start = func(name, attr)
276   {
277     level += 1;
278
279     if( skip )
280       return;
281
282     if( level == 1 )
283     {
284       if( name != 'svg' )
285         die("Not an svg file (root=" ~ name ~ ")");
286       else
287         return;
288     }
289     
290     var style = parseStyle(attr['style']);
291
292     if( style['display'] == 'none' )
293     {
294       skip = level - 1;
295       return;
296     }
297     else if( name == "g" )
298     {
299       pushElement('group', attr['id']);
300     }
301     else if( name == "text" )
302     {
303       pushElement('text', attr['id']);
304       stack[-1].setTranslation(attr['x'], attr['y']);
305       
306       # http://www.w3.org/TR/SVG/text.html#TextAnchorProperty
307       var h_align = style["text-anchor"];
308       if( h_align == "end" )
309         h_align = "right";
310       else if( h_align == "middle" )
311         h_align = "center";
312       else # "start"
313         h_align = "left";
314       stack[-1].setAlignment(h_align ~ "-baseline");
315       # TODO vertical align
316       
317       stack[-1].setColor(parseColor(style['fill']));
318       stack[-1].setFont
319       (
320         font_mapper(style["font-family"], style["font-weight"])
321       );
322
323       var font_size = style["font-size"];
324       if( font_size != nil )
325         # eg. font-size: 123px
326         stack[-1].setFontSize(substr(font_size, 0, size(font_size) - 2));
327     }
328     else if( name == "path" or name == "rect" )
329     {
330       pushElement('path', attr['id']);
331       var d = attr['d'];
332
333       if( name == "rect" )
334       {
335         var width = attr['width'];
336         var height = attr['height'];
337         var x = attr['x'];
338         var y = attr['y'];
339
340         d = sprintf("M%f,%f v%f h%f v%fz", x, y, height, width, -height);
341       }
342       
343       parsePath(d);
344       
345       var w = style['stroke-width'];
346       stack[-1].setStrokeLineWidth( w != nil ? w : 1 );
347       stack[-1].setColor(parseColor(style['stroke']));
348       
349       var linecap = style['stroke-linecap'];
350       if( linecap != nil )
351         stack[-1].setStrokeLineCap(style['stroke-linecap']);
352       
353       var fill = style['fill'];
354       if( fill != nil and fill != "none" )
355         stack[-1].setColorFill(parseColor(fill));
356       
357       # http://www.w3.org/TR/SVG/painting.html#StrokeDasharrayProperty
358       var dash = style['stroke-dasharray'];
359       if( dash and size(dash) > 3 )
360         # at least 2 comma separated values...
361         stack[-1].setStrokeDashArray(split(',', dash));
362
363       var cx = attr['inkscape:transform-center-x'];
364       var cy = attr['inkscape:transform-center-y'];
365       if( cx != nil or cy != nil )
366         stack[-1].setCenter(cx or 0, -(cy or 0));
367     }
368     else if( name == "tspan" )
369     {
370       return;
371     }
372     else if( name == "use" )
373     {
374       var ref = attr["xlink:href"];
375       if( ref == nil or size(ref) < 2 or ref[0] != `#` )
376         return debug.dump("Invalid or missing href", ref);
377
378       var el_src = id_dict[ substr(ref, 1) ];
379       if( el_src == nil )
380         return print("parsesvg: Reference to unknown element (" ~ ref ~ ")");
381       
382       # Create new element and copy sub branch from source node
383       pushElement(el_src._node.getName(), attr['id']);
384       props.copy(el_src._node, stack[-1]._node);
385
386       # copying also overrides the id so we need to set it again
387       stack[-1]._node.getNode("id").setValue(attr['id']);
388     }
389     else
390     {
391       print("parsesvg: skipping unknown element '" ~ name ~ "'");
392       skip = level;
393       return;
394     }
395
396     parseTransform(attr['transform']);
397   };
398
399   # XML parsers element close callback
400   var end = func(name)
401   {
402     level -= 1;
403     
404     if( skip )
405     {
406       if( level <= skip )
407         skip = 0;
408       return;
409     }
410     
411     if( size(close_stack) and (level + 1) == close_stack[-1] )
412     {
413       pop(stack);
414       pop(close_stack);
415     }
416   };
417
418   # XML parsers element data callback
419   var data = func(data)
420   {
421     if( skip )
422       return;
423     
424     if( size(data) and isa(stack[-1], Text) )
425       stack[-1].setText(data);
426   };
427
428   if( path[0] != '/' )
429     path = getprop("/sim/fg-root") ~ "/" ~ path;
430
431   call(func parsexml(path, start, end, data), nil, var err = []);
432   if( size(err) )
433   {
434     debug.dump(err);
435     return 0;
436   }
437   
438   return 1;
439 }