bcrowell / opengrade

software for teachers to keep track of grades

This URL has Read+Write access

opengrade / Report.pm
100644 598 lines (550 sloc) 21.964 kb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
#----------------------------------------------------------------
# Copyright (c) 2002 Benjamin Crowell, all rights reserved.
#
# This software is available under version 2 of the GPL license.
# The software is copyrighted, and you must agree to the
# license in order to have permission to copy it. The full
# text of the license is given in the file titled Copying.
#
#----------------------------------------------------------------
 
 
use strict;
 
use GradeBook;
use Crunch;
use Text;
use NetOG;
use POSIX;
use POSIX ":sys_wait_h";
 
package Report;
 
sub stats {
    my %args = (
                GB=>"",
                FORMAT=>"plain",
                @_,
                );
    my $gb = $args{GB};
    my $format = $args{FORMAT};
    my $t = Text->new($format);
 
    my $h = Crunch::class_stats($gb,"all");
    my $possible = Crunch::possible($gb,"all");
    my %h = %$h;
    foreach my $c(keys(%h)) {
      my $stats = $h{$c};
      my ($n,$mean,$sd) = (
        GradeBook::get_property($stats,"n"),
        GradeBook::get_property($stats,"mean"),
        GradeBook::get_property($stats,"sd"),
      );
      #print "$c possible = ".$possible->{$c}."\n";
      my $p = $possible->{$c};
      my $stuff = "";
      if ($p > 0.) {
        $stuff = (sprintf "n=%3d mean=%7.2f%s sd=%7.2f%s",
                        $n,$mean/$p*100.0,"%",$sd/$p*100.0,"%"
                  );
      }
      else {
        $stuff = "";
      }
      $t->put(TEXT=>(sprintf "%10s %s\n",$c,$stuff));
    }
 
    return $t;
}
 
sub statistics_ass {
    my $gb = shift;
    my $format = shift;
    my $cat = shift;
    my $ass = shift;
    my @student_keys = $gb->student_keys();
    my $t = Text->new($format);
    my @scores = ();
    my $title;
    $title=$gb->assignment_name($cat,$ass);
    foreach my $student (@student_keys) {
      my $score;
      $score = $gb->get_current_grade($student,$cat,$ass);
      push @scores,$score;
    }
    my ($n,$mean,$sd) = Crunch::stats(\@scores);
    my $p = $gb->assignment_property("$cat.$ass",'max');
    my $sd_of_mean_percent = undef;
    if ($n>0 && $p>0) {$sd_of_mean_percent = $sd*100.0/(sqrt($n)*$p)}
    $t->put(P=>1,TEXT=>$title);
    my $stuff = '';
    if ($p > 0.) {
      $stuff = (sprintf " n = %5d\nmean = %7.1f = %7.2f%s +- %7.2f%s\n sd = %7.1f = %7.2f%s",
                        $n,$mean,$mean/$p*100.0,"%",$sd_of_mean_percent,"%",$sd,$sd/$p*100.0,"%"
                  );
}
    $t->put(BR=>1,TEXT=>$stuff);
    return $t;
}
 
# use:
# sort(gb,format,mp)
# sort(gb,format,mp,cat)
# sort(gb,format,mp,cat,ass)
# trailing * on format means sort by name
# For a gradebook without marking periods, mp is ignored.
# For a gb with mps, mp can be specified, or set to undef for overall grade.
sub sort {
    my $gb = shift;
    my $format = shift;
    my $mp = shift;
    my $cat = '';
    if (@_) {$cat=shift}
    my $ass = '';
    if (@_) {$ass=shift}
    my $sort_by = 'score';
    if ($format=~m/\*$/) {$sort_by='name'; $format=~s/\*$//} # kludge: trailing * means sort by name
    my @student_keys = $gb->student_keys();
    my $t = Text->new($format);
    my @stuff = ();
    my $title;
    if ($cat eq '') {$title=''}
    if ($cat ne '' && $ass eq '') {$title=$gb->category_name_plural($cat)}
    if ($ass ne '') {$title=$gb->assignment_name($cat,$ass)}
    foreach my $student (@student_keys) {
      my $score;
      if ($cat eq '') {$score = Crunch::totals($gb,$student,'all',$mp)->{'all'}; $title=''}
      if ($cat ne '' && $ass eq '') {$score = Crunch::total_one_cat($gb,$student,$cat,$gb->array_of_assignments_in_category($cat),1,$mp)}
      if ($ass ne '') {$score = $gb->get_current_grade($student,$cat,$ass)."/".$gb->assignment_property("$cat.$ass","max")}
      $score =~ m@^([^/]*)@.*@;
      my $points = $1;
      push @stuff,[$student,$points,$score];
    }
    if ($sort_by eq 'score') {
      @stuff = sort {$b->[1] <=> $a->[1]} @stuff;
    }
    else { # sort by name
      @stuff = sort {$gb->compare_names($a->[0],$b->[0])} @stuff;
    }
    $t->put(P=>1,TEXT=>$title);
    foreach my $thing (@stuff) {
      my $student = $thing->[0];
      my $score = $thing->[2];
      my ($first,$last) = $gb->name($student);
      my $display_name = "$first $last";
      if (length($display_name)>20) {$display_name = substr($display_name,0,17).'...'}
      my $display = fraction_to_display($gb,$score);
      $display='--' if $score=~m@^/@;
      $t->put(BR=>1,TEXT=>(sprintf "%-20s %10s (%s)" , $display_name, $display,format_fraction($score)));
    }
    return $t;
}
 
# Returns a Text object
sub class_totals {
    my $gb = shift;
    my $format = shift;
    my @student_keys = $gb->student_keys();
    my $t = Text->new($format);
    foreach my $student (@student_keys) {
        my ($first,$last) = $gb->name($student);
        my $full_name = $first." ".$last;
        my $totals = Crunch::totals($gb,$student);
        my $all = $totals->{"all"};
        $t->put(TEXT=>(sprintf "%20s %s\n",$full_name,
                       fraction_to_display($gb,$all)." ($all)"));
    }
    $t->put(BR=>1,TEXT=>(sprintf "%20s %d students","",1+$#student_keys));
    return $t;
}
 
# Returns a Text object
sub class_totals_one_mp {
    my $gb = shift;
    my $format = shift;
    my $mp = shift;
    my @student_keys = $gb->student_keys();
    my $t = Text->new($format);
    foreach my $student (@student_keys) {
        my ($first,$last) = $gb->name($student);
        my $full_name = $first." ".$last;
        my $totals = Crunch::totals($gb,$student,'all',$mp);
        my $all = $totals->{"all"};
        $t->put(TEXT=>(sprintf "%20s %s\n",$full_name,
                       fraction_to_display($gb,$all)." ($all)"));
    }
    $t->put(BR=>1,TEXT=>(sprintf "%20s %d students","",1+$#student_keys));
    return $t;
}
 
 
sub make_student_report {
  my $gb = shift;
  my $who = shift;
  my ($first,$last) = $gb->name($who);
  my $t = student(GB=>$gb,STUDENT=>$who,FORMAT=>"html");
  return "<html><head><title>Grades for $first $last</title></head><body>\n"
               . $t->text()
               . "</body></html>\n";
}
 
=head3 upload_grades()
 
GB=>'',
PWD=>'',
PROGRESS_BAR_CALLBACK=>sub{},
FINAL_CALLBACK=>sub{},
PROTOCOL=>'og',
 
This is meant to be something we can call from either the GUI or the text-based
interface, without duplicating any code. No other protocols are supported besides the default one.
 
=cut
 
sub upload_grades {
      my %args = (
        GB=>'',
        PWD=>'',
        PROGRESS_BAR_CALLBACK=>sub{},
        FINAL_CALLBACK=>sub{},
        PROTOCOL=>'og',
        @_
      );
      my $gb = $args{GB};
      my $password = $args{PWD};
      my $progress_bar = $args{PROGRESS_BAR_CALLBACK};
      my $protocol = $args{PROTOCOL};
      my $dir = $gb->dir(); # e.g. bcrowell/s2002/205 ; relative to cgi-bin
      my $final_callback = $args{FINAL_CALLBACK};
      my $result = '';
      my $backup = 1;
      if ($protocol ne 'og') {
        return 'og is the only supported protocol';
      }
      my $request = NetOG->new();
      my $prefs = $gb->preferences();
      my $server_domain = $prefs->get('server_domain');
      my $server_user = $prefs->get('server_user');
      my $server_account = $prefs->get('server_account');
      my $server_key = $prefs->get('server_key');
      my $server_class = $gb->dir();
      my $ndone = 0;
      my @student_keys = $gb->student_keys();
      my $err = '';
      my %reports = ();
      foreach my $student(@student_keys) {
        $reports{$student} = make_student_report($gb,$student);
      }
      # The following code for uploading reports is designed to be robust when used over a slow
      # or unreliable connection. It used to be just a simple loop with a be_client() call inside,
      # but sometimes the server would fail to respond for an indefinite period, and it would hang.
      # This new version forks a child process to do each upload, and if the child doesn't exit
      # within a certain amount of time, it waits a little longer out of politeness to the
      # server (which may be overloaded) and then goes on to the next report. After going down the
      # roster, it then repeats the process for any students whose reports it failed to upload the
      # first time. With each iteration, it sets longer time limits. If this doesn't succeed after
      # a certain number of iterations, it gives up and returns an error. The tunable parameters
      # are the times, in units of seconds, given in $max_tries, $politeness_delay, and $tmax.
      my $n_tries = 0;
      my $max_tries = 5; # if changing this, change the arrays used to set tmax and politeness_delay below
      my $debug = 0;
      while (keys %reports) {
   ++$n_tries;
if ($n_tries>=2) {print "retrying\n" if $debug}
        if ($n_tries>=$max_tries) {$err= "giving up after $n_tries tries\n"; last}
        my $politeness_delay = [1,5,10,20,30]->[$n_tries-1]; # seconds
        my $tmax = [10,15,20,30,40]->[$n_tries-1]; # seconds
        print "politeness_delay=$politeness_delay\n" if $debug;
my @to_do = sort keys %reports;
        foreach my $student(@to_do) {
          my $kid = fork;
          if ($kid==0) { # I'm the child
            $request->be_client(GB=>$gb,
              HOST=>$server_domain,SERVER_KEY=>$server_key,
              PARS=>{'account'=>$server_account,'user'=>$server_user,'class'=>$server_class,
                     'what'=>'upload_grade_report','who'=>$student,'report'=>$reports{$student}}
            );
            POSIX::_exit(0); # no cleanup, closing files, etc.
}
          else {
            my $t = 0;
            my $dt = 1;
            print "$student\n" if $debug;
            for (;;) {
              sleep $dt;
              my $dead = ($kid==waitpid($kid,&POSIX::WNOHANG));
              if ($dead) {
                delete($reports{$student});
                ++$ndone;
                last;
              }
              $t += $dt;
              if ($t>$tmax) {
                print "Error uploading grade report for $student, timed out after $tmax sec.\n" if $debug;
                kill 9,$kid;
                sleep $politeness_delay; # be polite to server, maybe it's overloaded
                last;
              }
            }
          }
          $result = $request->{RESPONSE_DATA};
          if ($result=~m/^error/) {$err=$result; last}
          if (ref $progress_bar) {&$progress_bar($ndone/($#student_keys+1))}
        } # end loop over students
      }
      &$final_callback($err);
      return $err;
}
 
sub format_percent_letter_and_fraction {
        my $gb = shift;
        my $frac = shift;
        my $relative = fraction_to_display($gb,$frac);
        my $absolute = format_fraction($frac);
        if ($relative ne '') {
          return "$relative ($absolute)";
        }
        else {
          return $absolute;
        }
}
 
sub format_fraction {
        my $frac = shift;
        if ($frac eq '' || $frac eq '--') {return $frac}
        my ($total,$possible) = split("/",$frac);
        $possible = sprintf "%d",int($possible);
        $total = sprintf "%".length(int($possible))."d",int($total);
        return sprintf "$total/$possible";
}
 
sub fraction_to_display {
        my $gb = shift;
        my $frac = shift;
        my ($percentage,$letter) = fraction_to_pct_and_letter($gb,$frac);
        if ($percentage eq "" && $letter eq "") {return ""}
        if ($letter eq "") {return sprintf "%4.1f",$percentage}
        if (length $letter<2) {$letter = "$letter "}
        return sprintf "%4.1f%s %s",$percentage,'%',$letter;
}
 
 
sub fraction_to_pct_and_letter {
        my $gb = shift;
        my $frac = shift;
        if ($frac eq '' || $frac eq '--') {return $frac}
        my ($total,$possible) = split("/",$frac);
        my $percentage = 0.0;
        my $letter = "";
        if ($possible>0) {
            $percentage = 100.*$total/$possible;
            $letter = $gb->percentage_to_letter_grade($percentage);
        }
        else {
            $percentage = "";
            $letter = "";
        }
        return ($percentage,$letter);
}
 
sub table {
    my %args = (
                GB=>"",
                FORMAT=>"plain",
                @_,
               );
    my $gb = $args{GB};
    my $format = $args{FORMAT};
    my @student_keys = $gb->student_keys();
    my $t = Text->new($format);
 
    my @a = split(",",$gb->assignment_list()); # list of cat.ass
    my @c = (); # cat only
    my @n = (); # ass only
    foreach my $a(@a) {
      my ($cat,$ass) = (GradeBook::first_part_of_label($a),
                              GradeBook::second_part_of_label($a));
      push @c,$cat;
      push @n,$ass;
    }
    my $n_assignments = @a;
    my $assignments_per_page = 12;
    my $width = 5;
    my $name_width = 15;
    my $full_width = $name_width+$assignments_per_page*$width;
    my $n_pages = int($n_assignments/$assignments_per_page);
    if ($n_pages * $assignments_per_page < $n_assignments) {$n_pages++}
 
    # Helper routine to trim or pad a string to a desired width:
    my $trim = sub {
      my $string = shift;
      my $width = shift;
      my $result = sprintf "%${width}s",$string;
      if (length $result>$width) {$result = substr($result,0,$width)}
      return $result;
    };
 
    for (my $page=0; $page<$n_pages; $page++) {
      my $offset = $page*$assignments_per_page;
      my $n_cols = $assignments_per_page;
      if ($offset+$n_cols>$n_assignments) {$n_cols = $n_assignments-$offset}
 
      $t->put(TEXT=>(' ' x $name_width));
      for (my $col=0; $col<$n_cols; $col++) {
        $t->put(TEXT=>&$trim($c[$offset+$col]." ",$width));
      }
      $t->put(BR=>1);
 
      $t->put(TEXT=>(' ' x $name_width));
      for (my $col=0; $col<$n_cols; $col++) {
        $t->put(TEXT=>&$trim($n[$offset+$col]." ",$width));
      }
      $t->put(BR=>1);
 
      $t->put(TEXT=>(' ' x $name_width));
      for (my $col=0; $col<$n_cols; $col++) {
        $t->put(TEXT=>&$trim("(".$gb->assignment_property($a[$offset+$col],"max").")",$width));
      }
      $t->put(BR=>1);
 
      foreach my $student (@student_keys) {
        my ($first,$last) = $gb->name($student);
        my $full_name = "$last, $first";
        if (length $full_name>$name_width-1) {$full_name=substr($full_name,0,$name_width-1)}
        $t->put(TEXT=>&$trim($full_name." ",$name_width));
        for (my $col=0; $col<$n_cols; $col++) {
          my $j = $offset+$col;
          my $grade = $gb->get_current_grade($student,$c[$j],$n[$j]);
          if ($grade eq '') {$grade='--'}
          $t->put(TEXT=>(sprintf "%${width}s",$grade." "));
        }
        $t->put(BR=>1);
      }
 
      $t->put(P=>1);
 
    }
 
 
    return ($t,$full_width);
}
 
sub student {
  my %args = (
                GB=>"",
                STUDENT=>"",
                FORMAT=>"plain",
                @_,
               );
  my $gb = $args{GB};
  my $format = $args{FORMAT};
  my $student = $args{STUDENT};
  my ($first,$last) = $gb->name($student);
  my $full_name = $first." ".$last;
  my $t = Text->new($format);
  $t->put(P=>1,TEXT=>"$first $last, ".$gb->title());
  my @c = split(",",$gb->category_list());
 
  my @mps = (undef,);
  if ($gb->marking_periods()) {@mps = $gb->marking_periods_in_order()}
  foreach my $mp (@mps) {
    my $period_total = Crunch::totals($gb,$student,'all',$mp);
    if ($mp) { $t->put(BR=>1,TEXT=>"================ $mp =============="); }
    my $all = $period_total->{"all"};
    $t->put(P=>1,TEXT=>"overall grade: ".format_percent_letter_and_fraction($gb,$all));
    foreach my $c(@c) {
      my $totals = Crunch::totals($gb,$student,$c,$gb->array_of_assignments_in_category($c),$mp);
      my $single_assignment_category = $gb->category_property_boolean($c,'single');
      my $category_total = format_percent_letter_and_fraction($gb,$totals->{$c});
      my $show_category_total;
      my @a = split(",",$gb->assignment_list());
      if ( !empty_array_ref($gb->array_of_assignments_in_category($c)) # There really are one or more assignments in it, not zero.
          && $gb->assignment_properties($c.'.'.(($gb->array_of_assignments_in_category($c))->[0]))->{'ignore'} eq 'true'
         ) {
        # This is a category that's ignored for purposes of computing students' grades. This requires special handling. Normally
        # for an ignored category, we'd just put 0/0 for the category total, and the report would show the individual (ignored) grades on the assignments
        # below the category heading. But we have to do something different for the case of a single-assignment category, because then it would
        # always show 0/0, and you could never tell from the report what the (ignored) score was. We do this in categories that aren't single-assignment as well,
        # just to avoid confusion.
        if ($single_assignment_category) {
          my $a = (($gb->array_of_assignments_in_category($c))->[0]);
          my $grade = $gb->get_current_grade($student,$c,$a);
          if ($grade eq '') {$grade='no credit recorded'}
          $show_category_total = "($grade) (not counted in computing grades)" if (!$mp || $gb->assignment_properties("$c.$a")->{'mp'} eq $mp);
          # Since this is a single-assignment category, this is the only way they'll see their (ignored) points.
        }
        else {
          my $ignored_cat_total = Crunch::total_one_cat($gb,$student,$c,$gb->array_of_assignments_in_category($c),1);
          $show_category_total = "($ignored_cat_total, not counted in computing grades)";
          # $category_total is just 0/0, so no point in showing it -- it just confuses them
        }
      }
      else {
        # not an ignored category
        $show_category_total = $category_total; # if not during this mp, this will be 0/0
        # Check the special case where it's a single-assignment category, and the assignment isn't due yet:
        if ($single_assignment_category && @a) {
          my $ass = GradeBook::second_part_of_label($a[0]);
          my $due_date = $gb->assignment_properties("$c.$ass")->{"due"};
          my $due = ($due_date eq "") || (DateOG::is_past(DateOG::disambiguate_year($due_date,$gb->term())));
          if (!$due && (!$mp || $gb->assignment_properties("$c.$ass")->{'mp'} eq $mp)) {
            $show_category_total = $show_category_total . " (not counted because it isn't due yet)";
          }
        }
      }
      $t->put(BR=>1,TEXT=>$gb->category_name_plural($c).": ".$show_category_total);
      my $sing = $gb->category_name_singular($c);
      if (!$single_assignment_category) {
        foreach my $a(@a) {
          my ($cat,$ass) = (GradeBook::first_part_of_label($a),
                            GradeBook::second_part_of_label($a));
          if ($cat eq $c) {
            my $a = "$cat.$ass";
            my $type = $gb->category_property2($cat,'type');
            my $ass_properties = $gb->assignment_properties($a);
            my $due_date = $ass_properties->{"due"};
            my $ignore = ($ass_properties->{"ignore"} eq "true");
            my $due = ($due_date eq "") || (DateOG::is_past(DateOG::disambiguate_year($due_date,$gb->term())));
            my $grade = $gb->get_current_grade($student,$cat,$ass);
            if ($grade eq "") {$grade="--"}
            my $max = $gb->assignment_property($a,"max");
            my $ass_name = $gb->assignment_name($cat,$ass);
            my $explain_whether_due = '';
            $explain_whether_due=" (not counted for computing grades, because it isn't due until $due_date)" if !$due; # $due_date guaranteed not null, because then $due is true
            my $fraction = "$grade/$max";
            if ($type ne 'numerical') {$fraction = $gb->types()->{'data'}->{$type}->{'descriptions'}->{$grade}}
            $fraction = $grade if ($ignore && $max==0);
            $fraction = "$grade (extra credit)" if (!$ignore && $max==0 && $grade>=0);
            $t->put(TEXT=>"$ass_name: $fraction$explain_whether_due",INDENTATION=>1,BR=>1) if (!$mp || $ass_properties->{'mp'} eq $mp);
          }
        } # end foreach assignment
      } # end if not single-assignment cat
    } # end loop over cats
  } # end loop over marking periods
  return $t;
}
 
sub empty_array_ref {
  my $ref = shift;
  return !(@$ref);
}
 
sub roster_to_svg {
 
my $n = shift;
my $title = shift;
my @names = @$n;
 
my $svg_head = <<'SVG';
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
width="744.09448819"
height="1052.3622047"
>
SVG
my $svg_tail = <<'SVG';
</svg>
SVG
 
my $svg_body = '';
my $y_offset = 125;
my $x_text = 51;
my $y_text = 56;
my $line_spacing = 22.5;
 
my $y_title = $y_offset;
my $x_title = $x_text+250;
$svg_body = $svg_body . <<SVG;
<text
xml:space="preserve"
style="font-size:18px;font-style:normal;font-weight:normal;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;font-family:Bitstream Vera Sans"
x="50"
y="$y_title">
<tspan x='$x_title' y='$y_title'>$title</tspan>
</text>
SVG
for (my $i=0; $i<=@names-1; $i+=6) {
  my $y_rect = 56 + $i*$line_spacing + $y_offset;
  $svg_body = $svg_body . <<SVG;
<rect
style="fill:#dedede;fill-opacity:1;stroke:none;"
width="632"
height="67"
x="49"
y="$y_rect" />
SVG
}
$svg_body = $svg_body . <<'SVG';
<text
xml:space="preserve"
style="font-size:14px;font-style:normal;font-weight:normal;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;font-family:Bitstream Vera Sans"
x="51"
y="$y_text">
SVG
for (my $i=0; $i<=@names-1; $i++) {
  my $y = 71.9 + $line_spacing *$i + $y_offset;
  $svg_body = $svg_body . "<tspan x='$x_text' y='$y'>$names[$i]</tspan>\n";
}
$svg_body = $svg_body . "</text>\n";
 
return $svg_head,$svg_body,$svg_tail;
 
}
 
 
1;