1''' 2Created on May 16, 2011 3 4@author: bungeman 5''' 6import sys 7import getopt 8import re 9import os 10import bench_util 11import json 12import xml.sax.saxutils 13 14def usage(): 15 """Prints simple usage information.""" 16 17 print '-d <dir> a directory containing bench_r<revision>_<scalar> files.' 18 print '-b <bench> the bench to show.' 19 print '-c <config> the config to show (GPU, 8888, 565, etc).' 20 print '-t <time> the time to show (w, c, g, etc).' 21 print '-s <setting>[=<value>] a setting to show (alpha, scalar, etc).' 22 print '-r <revision>[:<revision>] the revisions to show.' 23 print ' Negative <revision> is taken as offset from most recent revision.' 24 print '-f <revision>[:<revision>] the revisions to use for fitting.' 25 print ' Negative <revision> is taken as offset from most recent revision.' 26 print '-x <int> the desired width of the svg.' 27 print '-y <int> the desired height of the svg.' 28 print '-l <title> title to use for the output graph' 29 print '--default-setting <setting>[=<value>] setting for those without.' 30 31 32class Label: 33 """The information in a label. 34 35 (str, str, str, str, {str:str})""" 36 def __init__(self, bench, config, time_type, settings): 37 self.bench = bench 38 self.config = config 39 self.time_type = time_type 40 self.settings = settings 41 42 def __repr__(self): 43 return "Label(%s, %s, %s, %s)" % ( 44 str(self.bench), 45 str(self.config), 46 str(self.time_type), 47 str(self.settings), 48 ) 49 50 def __str__(self): 51 return "%s_%s_%s_%s" % ( 52 str(self.bench), 53 str(self.config), 54 str(self.time_type), 55 str(self.settings), 56 ) 57 58 def __eq__(self, other): 59 return (self.bench == other.bench and 60 self.config == other.config and 61 self.time_type == other.time_type and 62 self.settings == other.settings) 63 64 def __hash__(self): 65 return (hash(self.bench) ^ 66 hash(self.config) ^ 67 hash(self.time_type) ^ 68 hash(frozenset(self.settings.iteritems()))) 69 70def get_latest_revision(directory): 71 """Returns the latest revision number found within this directory. 72 """ 73 latest_revision_found = -1 74 for bench_file in os.listdir(directory): 75 file_name_match = re.match('bench_r(\d+)_(\S+)', bench_file) 76 if (file_name_match is None): 77 continue 78 revision = int(file_name_match.group(1)) 79 if revision > latest_revision_found: 80 latest_revision_found = revision 81 if latest_revision_found < 0: 82 return None 83 else: 84 return latest_revision_found 85 86def parse_dir(directory, default_settings, oldest_revision, newest_revision): 87 """Parses bench data from files like bench_r<revision>_<scalar>. 88 89 (str, {str, str}, Number, Number) -> {int:[BenchDataPoints]}""" 90 revision_data_points = {} # {revision : [BenchDataPoints]} 91 for bench_file in os.listdir(directory): 92 file_name_match = re.match('bench_r(\d+)_(\S+)', bench_file) 93 if (file_name_match is None): 94 continue 95 96 revision = int(file_name_match.group(1)) 97 scalar_type = file_name_match.group(2) 98 99 if (revision < oldest_revision or revision > newest_revision): 100 continue 101 102 file_handle = open(directory + '/' + bench_file, 'r') 103 104 if (revision not in revision_data_points): 105 revision_data_points[revision] = [] 106 default_settings['scalar'] = scalar_type 107 revision_data_points[revision].extend( 108 bench_util.parse(default_settings, file_handle)) 109 file_handle.close() 110 return revision_data_points 111 112def create_lines(revision_data_points, settings 113 , bench_of_interest, config_of_interest, time_of_interest): 114 """Convert revision data into sorted line data. 115 116 ({int:[BenchDataPoints]}, {str:str}, str?, str?, str?) 117 -> {Label:[(x,y)] | [n].x <= [n+1].x}""" 118 revisions = revision_data_points.keys() 119 revisions.sort() 120 lines = {} # {Label:[(x,y)] | x[n] <= x[n+1]} 121 for revision in revisions: 122 for point in revision_data_points[revision]: 123 if (bench_of_interest is not None and 124 not bench_of_interest == point.bench): 125 continue 126 127 if (config_of_interest is not None and 128 not config_of_interest == point.config): 129 continue 130 131 if (time_of_interest is not None and 132 not time_of_interest == point.time_type): 133 continue 134 135 skip = False 136 for key, value in settings.items(): 137 if key in point.settings and point.settings[key] != value: 138 skip = True 139 break 140 if skip: 141 continue 142 143 line_name = Label(point.bench 144 , point.config 145 , point.time_type 146 , point.settings) 147 148 if line_name not in lines: 149 lines[line_name] = [] 150 151 lines[line_name].append((revision, point.time)) 152 153 return lines 154 155def bounds(lines): 156 """Finds the bounding rectangle for the lines. 157 158 {Label:[(x,y)]} -> ((min_x, min_y),(max_x,max_y))""" 159 min_x = bench_util.Max 160 min_y = bench_util.Max 161 max_x = bench_util.Min 162 max_y = bench_util.Min 163 164 for line in lines.itervalues(): 165 for x, y in line: 166 min_x = min(min_x, x) 167 min_y = min(min_y, y) 168 max_x = max(max_x, x) 169 max_y = max(max_y, y) 170 171 return ((min_x, min_y), (max_x, max_y)) 172 173def create_regressions(lines, start_x, end_x): 174 """Creates regression data from line segments. 175 176 ({Label:[(x,y)] | [n].x <= [n+1].x}, Number, Number) 177 -> {Label:LinearRegression}""" 178 regressions = {} # {Label : LinearRegression} 179 180 for label, line in lines.iteritems(): 181 regression_line = [p for p in line if start_x <= p[0] <= end_x] 182 183 if (len(regression_line) < 2): 184 continue 185 regression = bench_util.LinearRegression(regression_line) 186 regressions[label] = regression 187 188 return regressions 189 190def bounds_slope(regressions): 191 """Finds the extreme up and down slopes of a set of linear regressions. 192 193 ({Label:LinearRegression}) -> (max_up_slope, min_down_slope)""" 194 max_up_slope = 0 195 min_down_slope = 0 196 for regression in regressions.itervalues(): 197 min_slope = regression.find_min_slope() 198 max_up_slope = max(max_up_slope, min_slope) 199 min_down_slope = min(min_down_slope, min_slope) 200 201 return (max_up_slope, min_down_slope) 202 203def main(): 204 """Parses command line and writes output.""" 205 206 try: 207 opts, _ = getopt.getopt(sys.argv[1:] 208 , "d:b:c:l:t:s:r:f:x:y:" 209 , "default-setting=") 210 except getopt.GetoptError, err: 211 print str(err) 212 usage() 213 sys.exit(2) 214 215 directory = None 216 config_of_interest = None 217 bench_of_interest = None 218 time_of_interest = None 219 revision_range = '0:' 220 regression_range = '0:' 221 latest_revision = None 222 requested_height = None 223 requested_width = None 224 title = 'Bench graph' 225 settings = {} 226 default_settings = {} 227 228 def parse_range(range): 229 """Takes '<old>[:<new>]' as a string and returns (old, new). 230 Any revision numbers that are dependent on the latest revision number 231 will be filled in based on latest_revision. 232 """ 233 old, _, new = range.partition(":") 234 old = int(old) 235 if old < 0: 236 old += latest_revision; 237 if not new: 238 new = latest_revision; 239 new = int(new) 240 if new < 0: 241 new += latest_revision; 242 return (old, new) 243 244 def add_setting(settings, setting): 245 """Takes <key>[=<value>] adds {key:value} or {key:True} to settings.""" 246 name, _, value = setting.partition('=') 247 if not value: 248 settings[name] = True 249 else: 250 settings[name] = value 251 252 try: 253 for option, value in opts: 254 if option == "-d": 255 directory = value 256 elif option == "-b": 257 bench_of_interest = value 258 elif option == "-c": 259 config_of_interest = value 260 elif option == "-t": 261 time_of_interest = value 262 elif option == "-s": 263 add_setting(settings, value) 264 elif option == "-r": 265 revision_range = value 266 elif option == "-f": 267 regression_range = value 268 elif option == "-x": 269 requested_width = int(value) 270 elif option == "-y": 271 requested_height = int(value) 272 elif option == "-l": 273 title = value 274 elif option == "--default-setting": 275 add_setting(default_settings, value) 276 else: 277 usage() 278 assert False, "unhandled option" 279 except ValueError: 280 usage() 281 sys.exit(2) 282 283 if directory is None: 284 usage() 285 sys.exit(2) 286 287 latest_revision = get_latest_revision(directory) 288 oldest_revision, newest_revision = parse_range(revision_range) 289 oldest_regression, newest_regression = parse_range(regression_range) 290 291 revision_data_points = parse_dir(directory 292 , default_settings 293 , oldest_revision 294 , newest_revision) 295 296 # Update oldest_revision and newest_revision based on the data we could find 297 all_revision_numbers = revision_data_points.keys() 298 oldest_revision = min(all_revision_numbers) 299 newest_revision = max(all_revision_numbers) 300 301 lines = create_lines(revision_data_points 302 , settings 303 , bench_of_interest 304 , config_of_interest 305 , time_of_interest) 306 307 regressions = create_regressions(lines 308 , oldest_regression 309 , newest_regression) 310 311 output_xhtml(lines, oldest_revision, newest_revision, 312 regressions, requested_width, requested_height, title) 313 314def qa(out): 315 """Stringify input and quote as an xml attribute.""" 316 return xml.sax.saxutils.quoteattr(str(out)) 317def qe(out): 318 """Stringify input and escape as xml data.""" 319 return xml.sax.saxutils.escape(str(out)) 320 321def create_select(qualifier, lines, select_id=None): 322 """Output select with options showing lines which qualifier maps to it. 323 324 ((Label) -> str, {Label:_}, str?) -> _""" 325 options = {} #{ option : [Label]} 326 for label in lines.keys(): 327 option = qualifier(label) 328 if (option not in options): 329 options[option] = [] 330 options[option].append(label) 331 option_list = list(options.keys()) 332 option_list.sort() 333 print '<select class="lines"', 334 if select_id is not None: 335 print 'id=%s' % qa(select_id) 336 print 'multiple="true" size="10" onchange="updateSvg();">' 337 for option in option_list: 338 print '<option value=' + qa('[' + 339 reduce(lambda x,y:x+json.dumps(str(y))+',',options[option],"")[0:-1] 340 + ']') + '>'+qe(option)+'</option>' 341 print '</select>' 342 343def output_xhtml(lines, oldest_revision, newest_revision, 344 regressions, requested_width, requested_height, title): 345 """Outputs an svg/xhtml view of the data.""" 346 print '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"', 347 print '"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">' 348 print '<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">' 349 print '<head>' 350 print '<title>%s</title>' % title 351 print '</head>' 352 print '<body>' 353 354 output_svg(lines, regressions, requested_width, requested_height) 355 356 #output the manipulation controls 357 print """ 358<script type="text/javascript">//<![CDATA[ 359 function getElementsByClass(node, searchClass, tag) { 360 var classElements = new Array(); 361 var elements = node.getElementsByTagName(tag); 362 var pattern = new RegExp("^|\\s"+searchClass+"\\s|$"); 363 for (var i = 0, elementsFound = 0; i < elements.length; ++i) { 364 if (pattern.test(elements[i].className)) { 365 classElements[elementsFound] = elements[i]; 366 ++elementsFound; 367 } 368 } 369 return classElements; 370 } 371 function getAllLines() { 372 var selectElem = document.getElementById('benchSelect'); 373 var linesObj = {}; 374 for (var i = 0; i < selectElem.options.length; ++i) { 375 var lines = JSON.parse(selectElem.options[i].value); 376 for (var j = 0; j < lines.length; ++j) { 377 linesObj[lines[j]] = true; 378 } 379 } 380 return linesObj; 381 } 382 function getOptions(selectElem) { 383 var linesSelectedObj = {}; 384 for (var i = 0; i < selectElem.options.length; ++i) { 385 if (!selectElem.options[i].selected) continue; 386 387 var linesSelected = JSON.parse(selectElem.options[i].value); 388 for (var j = 0; j < linesSelected.length; ++j) { 389 linesSelectedObj[linesSelected[j]] = true; 390 } 391 } 392 return linesSelectedObj; 393 } 394 function objectEmpty(obj) { 395 for (var p in obj) { 396 return false; 397 } 398 return true; 399 } 400 function markSelectedLines(selectElem, allLines) { 401 var linesSelected = getOptions(selectElem); 402 if (!objectEmpty(linesSelected)) { 403 for (var line in allLines) { 404 allLines[line] &= (linesSelected[line] == true); 405 } 406 } 407 } 408 function updateSvg() { 409 var allLines = getAllLines(); 410 411 var selects = getElementsByClass(document, 'lines', 'select'); 412 for (var i = 0; i < selects.length; ++i) { 413 markSelectedLines(selects[i], allLines); 414 } 415 416 for (var line in allLines) { 417 var svgLine = document.getElementById(line); 418 var display = (allLines[line] ? 'inline' : 'none'); 419 svgLine.setAttributeNS(null,'display', display); 420 } 421 } 422 423 function mark(markerId) { 424 for (var line in getAllLines()) { 425 var svgLineGroup = document.getElementById(line); 426 var display = svgLineGroup.getAttributeNS(null,'display'); 427 if (display == null || display == "" || display != "none") { 428 var svgLine = document.getElementById(line+'_line'); 429 if (markerId == null) { 430 svgLine.removeAttributeNS(null,'marker-mid'); 431 } else { 432 svgLine.setAttributeNS(null,'marker-mid', markerId); 433 } 434 } 435 } 436 } 437//]]></script>""" 438 439 print '<table border="0" width="%s">' % requested_width 440 print """ 441<form> 442<tr valign="bottom" align="center"> 443<td width="1">Bench Type</td> 444<td width="1">Bitmap Config</td> 445<td width="1">Timer Type (Cpu/Gpu/wall)</td> 446<td width="1"><!--buttons--></td> 447<td width="10%"><!--spacing--></td>""" 448 449 print '<td>%s<br></br>revisions r%s - r%s</td>' % ( 450 title, 451 bench_util.CreateRevisionLink(oldest_revision), 452 bench_util.CreateRevisionLink(newest_revision)) 453 print '</tr><tr valign="top" align="center">' 454 print '<td width="1">' 455 create_select(lambda l: l.bench, lines, 'benchSelect') 456 print '</td><td width="1">' 457 create_select(lambda l: l.config, lines) 458 print '</td><td width="1">' 459 create_select(lambda l: l.time_type, lines) 460 461 all_settings = {} 462 variant_settings = set() 463 for label in lines.keys(): 464 for key, value in label.settings.items(): 465 if key not in all_settings: 466 all_settings[key] = value 467 elif all_settings[key] != value: 468 variant_settings.add(key) 469 470 for k in variant_settings: 471 create_select(lambda l: l.settings[k], lines) 472 473 print '</td><td width="1"><button type="button"', 474 print 'onclick=%s' % qa("mark('url(#circleMark)'); return false;"), 475 print '>Mark Points</button>' 476 print '<button type="button" onclick="mark(null);">Clear Points</button>' 477 478 print """ 479</td> 480<td width="10%"></td> 481<td align="left"> 482<p>Brighter red indicates tests that have gotten worse; brighter green 483indicates tests that have gotten better.</p> 484<p>To highlight individual tests, hold down CONTROL and mouse over 485graph lines.</p> 486<p>To highlight revision numbers, hold down SHIFT and mouse over 487the graph area.</p> 488<p>To only show certain tests on the graph, select any combination of 489tests in the selectors at left. (To show all, select all.)</p> 490<p>Use buttons at left to mark/clear points on the lines for selected 491benchmarks.</p> 492</td> 493</tr> 494</form> 495</table> 496</body> 497</html>""" 498 499def compute_size(requested_width, requested_height, rev_width, time_height): 500 """Converts potentially empty requested size into a concrete size. 501 502 (Number?, Number?) -> (Number, Number)""" 503 pic_width = 0 504 pic_height = 0 505 if (requested_width is not None and requested_height is not None): 506 pic_height = requested_height 507 pic_width = requested_width 508 509 elif (requested_width is not None): 510 pic_width = requested_width 511 pic_height = pic_width * (float(time_height) / rev_width) 512 513 elif (requested_height is not None): 514 pic_height = requested_height 515 pic_width = pic_height * (float(rev_width) / time_height) 516 517 else: 518 pic_height = 800 519 pic_width = max(rev_width*3 520 , pic_height * (float(rev_width) / time_height)) 521 522 return (pic_width, pic_height) 523 524def output_svg(lines, regressions, requested_width, requested_height): 525 """Outputs an svg view of the data.""" 526 527 (global_min_x, _), (global_max_x, global_max_y) = bounds(lines) 528 max_up_slope, min_down_slope = bounds_slope(regressions) 529 530 #output 531 global_min_y = 0 532 x = global_min_x 533 y = global_min_y 534 w = global_max_x - global_min_x 535 h = global_max_y - global_min_y 536 font_size = 16 537 line_width = 2 538 539 pic_width, pic_height = compute_size(requested_width, requested_height 540 , w, h) 541 542 def cw(w1): 543 """Converts a revision difference to display width.""" 544 return (pic_width / float(w)) * w1 545 def cx(x): 546 """Converts a revision to a horizontal display position.""" 547 return cw(x - global_min_x) 548 549 def ch(h1): 550 """Converts a time difference to a display height.""" 551 return -(pic_height / float(h)) * h1 552 def cy(y): 553 """Converts a time to a vertical display position.""" 554 return pic_height + ch(y - global_min_y) 555 556 print '<svg', 557 print 'width=%s' % qa(str(pic_width)+'px') 558 print 'height=%s' % qa(str(pic_height)+'px') 559 print 'viewBox="0 0 %s %s"' % (str(pic_width), str(pic_height)) 560 print 'onclick=%s' % qa( 561 "var event = arguments[0] || window.event;" 562 " if (event.shiftKey) { highlightRevision(null); }" 563 " if (event.ctrlKey) { highlight(null); }" 564 " return false;") 565 print 'xmlns="http://www.w3.org/2000/svg"' 566 print 'xmlns:xlink="http://www.w3.org/1999/xlink">' 567 568 print """ 569<defs> 570 <marker id="circleMark" 571 viewBox="0 0 2 2" refX="1" refY="1" 572 markerUnits="strokeWidth" 573 markerWidth="2" markerHeight="2" 574 orient="0"> 575 <circle cx="1" cy="1" r="1"/> 576 </marker> 577</defs>""" 578 579 #output the revisions 580 print """ 581<script type="text/javascript">//<![CDATA[ 582 var previousRevision; 583 var previousRevisionFill; 584 var previousRevisionStroke 585 function highlightRevision(id) { 586 if (previousRevision == id) return; 587 588 document.getElementById('revision').firstChild.nodeValue = 'r' + id; 589 document.getElementById('rev_link').setAttribute('xlink:href', 590 'http://code.google.com/p/skia/source/detail?r=' + id); 591 592 var preRevision = document.getElementById(previousRevision); 593 if (preRevision) { 594 preRevision.setAttributeNS(null,'fill', previousRevisionFill); 595 preRevision.setAttributeNS(null,'stroke', previousRevisionStroke); 596 } 597 598 var revision = document.getElementById(id); 599 previousRevision = id; 600 if (revision) { 601 previousRevisionFill = revision.getAttributeNS(null,'fill'); 602 revision.setAttributeNS(null,'fill','rgb(100%, 95%, 95%)'); 603 604 previousRevisionStroke = revision.getAttributeNS(null,'stroke'); 605 revision.setAttributeNS(null,'stroke','rgb(100%, 90%, 90%)'); 606 } 607 } 608//]]></script>""" 609 610 def print_rect(x, y, w, h, revision): 611 """Outputs a revision rectangle in display space, 612 taking arguments in revision space.""" 613 disp_y = cy(y) 614 disp_h = ch(h) 615 if disp_h < 0: 616 disp_y += disp_h 617 disp_h = -disp_h 618 619 print '<rect id=%s x=%s y=%s' % (qa(revision), qa(cx(x)), qa(disp_y),), 620 print 'width=%s height=%s' % (qa(cw(w)), qa(disp_h),), 621 print 'fill="white"', 622 print 'stroke="rgb(98%%,98%%,88%%)" stroke-width=%s' % qa(line_width), 623 print 'onmouseover=%s' % qa( 624 "var event = arguments[0] || window.event;" 625 " if (event.shiftKey) {" 626 " highlightRevision('"+str(revision)+"');" 627 " return false;" 628 " }"), 629 print ' />' 630 631 xes = set() 632 for line in lines.itervalues(): 633 for point in line: 634 xes.add(point[0]) 635 revisions = list(xes) 636 revisions.sort() 637 638 left = x 639 current_revision = revisions[0] 640 for next_revision in revisions[1:]: 641 width = (((next_revision - current_revision) / 2.0) 642 + (current_revision - left)) 643 print_rect(left, y, width, h, current_revision) 644 left += width 645 current_revision = next_revision 646 print_rect(left, y, x+w - left, h, current_revision) 647 648 #output the lines 649 print """ 650<script type="text/javascript">//<![CDATA[ 651 var previous; 652 var previousColor; 653 var previousOpacity; 654 function highlight(id) { 655 if (previous == id) return; 656 657 document.getElementById('label').firstChild.nodeValue = id; 658 659 var preGroup = document.getElementById(previous); 660 if (preGroup) { 661 var preLine = document.getElementById(previous+'_line'); 662 preLine.setAttributeNS(null,'stroke', previousColor); 663 preLine.setAttributeNS(null,'opacity', previousOpacity); 664 665 var preSlope = document.getElementById(previous+'_linear'); 666 if (preSlope) { 667 preSlope.setAttributeNS(null,'visibility', 'hidden'); 668 } 669 } 670 671 var group = document.getElementById(id); 672 previous = id; 673 if (group) { 674 group.parentNode.appendChild(group); 675 676 var line = document.getElementById(id+'_line'); 677 previousColor = line.getAttributeNS(null,'stroke'); 678 previousOpacity = line.getAttributeNS(null,'opacity'); 679 line.setAttributeNS(null,'stroke', 'blue'); 680 line.setAttributeNS(null,'opacity', '1'); 681 682 var slope = document.getElementById(id+'_linear'); 683 if (slope) { 684 slope.setAttributeNS(null,'visibility', 'visible'); 685 } 686 } 687 } 688//]]></script>""" 689 for label, line in lines.items(): 690 print '<g id=%s>' % qa(label) 691 r = 128 692 g = 128 693 b = 128 694 a = .10 695 if label in regressions: 696 regression = regressions[label] 697 min_slope = regression.find_min_slope() 698 if min_slope < 0: 699 d = max(0, (min_slope / min_down_slope)) 700 g += int(d*128) 701 a += d*0.9 702 elif min_slope > 0: 703 d = max(0, (min_slope / max_up_slope)) 704 r += int(d*128) 705 a += d*0.9 706 707 slope = regression.slope 708 intercept = regression.intercept 709 min_x = regression.min_x 710 max_x = regression.max_x 711 print '<polyline id=%s' % qa(str(label)+'_linear'), 712 print 'fill="none" stroke="yellow"', 713 print 'stroke-width=%s' % qa(abs(ch(regression.serror*2))), 714 print 'opacity="0.5" pointer-events="none" visibility="hidden"', 715 print 'points="', 716 print '%s,%s' % (str(cx(min_x)), str(cy(slope*min_x + intercept))), 717 print '%s,%s' % (str(cx(max_x)), str(cy(slope*max_x + intercept))), 718 print '"/>' 719 720 print '<polyline id=%s' % qa(str(label)+'_line'), 721 print 'onmouseover=%s' % qa( 722 "var event = arguments[0] || window.event;" 723 " if (event.ctrlKey) {" 724 " highlight('"+str(label).replace("'", "\\'")+"');" 725 " return false;" 726 " }"), 727 print 'fill="none" stroke="rgb(%s,%s,%s)"' % (str(r), str(g), str(b)), 728 print 'stroke-width=%s' % qa(line_width), 729 print 'opacity=%s' % qa(a), 730 print 'points="', 731 for point in line: 732 print '%s,%s' % (str(cx(point[0])), str(cy(point[1]))), 733 print '"/>' 734 735 print '</g>' 736 737 #output the labels 738 print '<text id="label" x="0" y=%s' % qa(font_size), 739 print 'font-size=%s> </text>' % qa(font_size) 740 741 print '<a id="rev_link" xlink:href="" target="_top">' 742 print '<text id="revision" x="0" y=%s style="' % qa(font_size*2) 743 print 'font-size: %s; ' % qe(font_size) 744 print 'stroke: #0000dd; text-decoration: underline; ' 745 print '"> </text></a>' 746 747 print '</svg>' 748 749if __name__ == "__main__": 750 main() 751