1# Copyright (c) 2010 Philip Taylor
2# Released under the BSD license and W3C Test Suite License: see LICENSE.txt
3
4# Current code status:
5#
6# This was originally written for use at
7# http://philip.html5.org/tests/canvas/suite/tests/
8#
9# It has been adapted for use with the Web Platform Test Suite suite at
10# https://github.com/w3c/web-platform-tests/
11#
12# The W3C version excludes a number of features (multiple versions of each test
13# case of varying verbosity, Mozilla mochitests, semi-automated test harness)
14# to focus on simply providing reviewable test cases. It also expects a different
15# directory structure.
16# This code attempts to support both versions, but the non-W3C version hasn't
17# been tested recently and is probably broken.
18
19# To update or add test cases:
20#
21# * Modify the tests*.yaml files.
22# 'name' is an arbitrary hierarchical name to help categorise tests.
23# 'desc' is a rough description of what behaviour the test aims to test.
24# 'testing' is a list of references to spec.yaml, to show which spec sentences
25# this test case is primarily testing.
26# 'code' is JavaScript code to execute, with some special commands starting with '@'
27# 'expected' is what the final canvas output should be: a string 'green' or 'clear'
28# (100x50 images in both cases), or a string 'size 100 50' (or any other size)
29# followed by Python code using Pycairo to generate the image.
30#
31# * Run "python gentest.py".
32# This requires a few Python modules which might not be ubiquitous.
33# It has only been tested on Linux.
34# It will usually emit some warnings, which ideally should be fixed but can
35# generally be safely ignored.
36#
37# * Test the tests, add new ones to Git, remove deleted ones from Git, etc.
38
39import re
40import codecs
41import time
42import os
43import shutil
44import sys
45import xml.dom.minidom
46from xml.dom.minidom import Node
47
48import cairo
49
50try:
51    import syck as yaml # compatible and lots faster
52except ImportError:
53    import yaml
54
55# Default mode is for the W3C test suite; the --standalone option
56# generates various extra files that aren't needed there
57W3CMODE = True
58if '--standalone' in sys.argv:
59    W3CMODE = False
60
61TESTOUTPUTDIR = '../../2dcontext'
62IMAGEOUTPUTDIR = '../../2dcontext'
63MISCOUTPUTDIR = './output'
64SPECOUTPUTDIR = '../../annotated-spec'
65
66SPECOUTPUTPATH = '../annotated-spec' # relative to TESTOUTPUTDIR
67
68def simpleEscapeJS(str):
69    return str.replace('\\', '\\\\').replace('"', '\\"')
70
71def escapeJS(str):
72    str = simpleEscapeJS(str)
73    str = re.sub(r'\[(\w+)\]', r'[\\""+(\1)+"\\"]', str) # kind of an ugly hack, for nicer failure-message output
74    return str
75
76def escapeHTML(str):
77    return str.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
78
79def expand_nonfinite(method, argstr, tail):
80    """
81    >>> print expand_nonfinite('f', '<0 a>, <0 b>', ';')
82    f(a, 0);
83    f(0, b);
84    f(a, b);
85    >>> print expand_nonfinite('f', '<0 a>, <0 b c>, <0 d>', ';')
86    f(a, 0, 0);
87    f(0, b, 0);
88    f(0, c, 0);
89    f(0, 0, d);
90    f(a, b, 0);
91    f(a, b, d);
92    f(a, 0, d);
93    f(0, b, d);
94    """
95    # argstr is "<valid-1 invalid1-1 invalid2-1 ...>, ..." (where usually
96    # 'invalid' is Infinity/-Infinity/NaN)
97    args = []
98    for arg in argstr.split(', '):
99        a = re.match('<(.*)>', arg).group(1)
100        args.append(a.split(' '))
101    calls = []
102    # Start with the valid argument list
103    call = [ args[j][0] for j in range(len(args)) ]
104    # For each argument alone, try setting it to all its invalid values:
105    for i in range(len(args)):
106        for a in args[i][1:]:
107            c2 = call[:]
108            c2[i] = a
109            calls.append(c2)
110    # For all combinations of >= 2 arguments, try setting them to their
111    # first invalid values. (Don't do all invalid values, because the
112    # number of combinations explodes.)
113    def f(c, start, depth):
114        for i in range(start, len(args)):
115            if len(args[i]) > 1:
116                a = args[i][1]
117                c2 = c[:]
118                c2[i] = a
119                if depth > 0: calls.append(c2)
120                f(c2, i+1, depth+1)
121    f(call, 0, 0)
122
123    return '\n'.join('%s(%s)%s' % (method, ', '.join(c), tail) for c in calls)
124
125# Run with --test argument to run unit tests
126if len(sys.argv) > 1 and sys.argv[1] == '--test':
127    import doctest
128    doctest.testmod()
129    sys.exit()
130
131templates = yaml.load(open('templates.yaml', "r").read())
132name_mapping = yaml.load(open('name2dir.yaml', "r").read())
133
134spec_assertions = []
135for s in yaml.load(open('spec.yaml', "r").read())['assertions']:
136    if 'meta' in s:
137        eval(compile(s['meta'], '<meta spec assertion>', 'exec'), {}, {'assertions':spec_assertions})
138    else:
139        spec_assertions.append(s)
140
141tests = []
142for t in sum([ yaml.load(open(f, "r").read()) for f in ['tests.yaml', 'tests2d.yaml', 'tests2dtext.yaml']], []):
143    if 'DISABLED' in t:
144        continue
145    if 'meta' in t:
146        eval(compile(t['meta'], '<meta test>', 'exec'), {}, {'tests':tests})
147    else:
148        tests.append(t)
149
150category_names = []
151category_contents_direct = {}
152category_contents_all = {}
153
154spec_ids = {}
155for t in spec_assertions: spec_ids[t['id']] = True
156spec_refs = {}
157
158def backref_html(name):
159    backrefs = []
160    c = ''
161    for p in name.split('.')[:-1]:
162        c += '.'+p
163        backrefs.append('<a href="index%s.html">%s</a>.' % (c, p))
164    backrefs.append(name.split('.')[-1])
165    return ''.join(backrefs)
166
167def make_flat_image(filename, w, h, r,g,b,a):
168    if os.path.exists('%s/%s' % (IMAGEOUTPUTDIR, filename)):
169        return filename
170    surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, w, h)
171    cr = cairo.Context(surface)
172    cr.set_source_rgba(r, g, b, a)
173    cr.rectangle(0, 0, w, h)
174    cr.fill()
175    surface.write_to_png('%s/%s' % (IMAGEOUTPUTDIR, filename))
176    return filename
177
178# Ensure the test output directories exist
179testdirs = [TESTOUTPUTDIR, IMAGEOUTPUTDIR, MISCOUTPUTDIR]
180if not W3CMODE: testdirs.append('%s/mochitests' % MISCOUTPUTDIR)
181else:
182    for map_dir in set(name_mapping.values()):
183        testdirs.append("%s/%s" % (TESTOUTPUTDIR, map_dir))
184for d in testdirs:
185    try: os.mkdir(d)
186    except: pass # ignore if it already exists
187
188mochitests = []
189used_images = {}
190
191def expand_test_code(code):
192    code = re.sub(r'@nonfinite ([^(]+)\(([^)]+)\)(.*)', lambda m: expand_nonfinite(m.group(1), m.group(2), m.group(3)), code) # must come before '@assert throws'
193
194    code = re.sub(r'@assert pixel (\d+,\d+) == (\d+,\d+,\d+,\d+);',
195            r'_assertPixel(canvas, \1, \2, "\1", "\2");',
196            code)
197
198    code = re.sub(r'@assert pixel (\d+,\d+) ==~ (\d+,\d+,\d+,\d+);',
199            r'_assertPixelApprox(canvas, \1, \2, "\1", "\2", 2);',
200            code)
201
202    code = re.sub(r'@assert pixel (\d+,\d+) ==~ (\d+,\d+,\d+,\d+) \+/- (\d+);',
203            r'_assertPixelApprox(canvas, \1, \2, "\1", "\2", \3);',
204            code)
205
206    code = re.sub(r'@assert throws (\S+_ERR) (.*);',
207            r'assert_throws("\1", function() { \2; });',
208            code)
209
210    code = re.sub(r'@assert throws (\S+Error) (.*);',
211            r'assert_throws(new \1(), function() { \2; });',
212            code)
213
214    code = re.sub(r'@assert throws (.*);',
215            r'assert_throws(null, function() { \1; });',
216            code)
217
218    code = re.sub(r'@assert (.*) === (.*);',
219            lambda m: '_assertSame(%s, %s, "%s", "%s");'
220                % (m.group(1), m.group(2), escapeJS(m.group(1)), escapeJS(m.group(2)))
221            , code)
222
223    code = re.sub(r'@assert (.*) !== (.*);',
224            lambda m: '_assertDifferent(%s, %s, "%s", "%s");'
225                % (m.group(1), m.group(2), escapeJS(m.group(1)), escapeJS(m.group(2)))
226            , code)
227
228    code = re.sub(r'@assert (.*) =~ (.*);',
229            lambda m: 'assert_regexp_match(%s, %s);'
230                % (m.group(1), m.group(2))
231            , code)
232
233    code = re.sub(r'@assert (.*);',
234            lambda m: '_assert(%s, "%s");'
235                % (m.group(1), escapeJS(m.group(1)))
236            , code)
237
238    code = re.sub(r' @moz-todo', '', code)
239
240    code = re.sub(r'@moz-UniversalBrowserRead;',
241            ""
242            , code)
243
244    assert('@' not in code)
245
246    return code
247
248def expand_mochitest_code(code):
249    code = re.sub(r'@nonfinite ([^(]+)\(([^)]+)\)(.*)', lambda m: expand_nonfinite(m.group(1), m.group(2), m.group(3)), code)
250
251    code = re.sub(r'@assert pixel (\d+,\d+) == (\d+,\d+,\d+,\d+);',
252        r'isPixel(ctx, \1, \2, "\1", "\2", 0);',
253        code)
254
255    code = re.sub(r'@assert pixel (\d+,\d+) ==~ (\d+,\d+,\d+,\d+);',
256        r'isPixel(ctx, \1, \2, "\1", "\2", 2);',
257        code)
258
259    code = re.sub(r'@assert pixel (\d+,\d+) ==~ (\d+,\d+,\d+,\d+) \+/- (\d+);',
260        r'isPixel(ctx, \1, \2, "\1", "\2", \3);',
261        code)
262
263    code = re.sub(r'@assert throws (\S+_ERR) (.*);',
264        lambda m: 'var _thrown = undefined; try {\n  %s;\n} catch (e) { _thrown = e }; ok(_thrown && _thrown.code == DOMException.%s, "should throw %s");'
265            % (m.group(2), m.group(1), m.group(1))
266        , code)
267
268    code = re.sub(r'@assert throws (\S+Error) (.*);',
269        lambda m: 'var _thrown = undefined; try {\n  %s;\n} catch (e) { _thrown = e }; ok(_thrown && (_thrown instanceof %s), "should throw %s");'
270            % (m.group(2), m.group(1), m.group(1))
271        , code)
272
273    code = re.sub(r'@assert throws (.*);',
274        lambda m: 'try { var _thrown = false;\n  %s;\n} catch (e) { _thrown = true; } finally { ok(_thrown, "should throw exception"); }'
275            % (m.group(1))
276        , code)
277
278    code = re.sub(r'@assert (.*) =~ (.*);',
279        lambda m: 'ok(%s.match(%s), "%s.match(%s)");'
280            % (m.group(1), m.group(2), escapeJS(m.group(1)), escapeJS(m.group(2)))
281        , code)
282
283    code = re.sub(r'@assert (.*);',
284        lambda m: 'ok(%s, "%s");'
285            % (m.group(1), escapeJS(m.group(1)))
286        , code)
287
288    code = re.sub(r'((?:^|\n|;)\s*)ok(.*;) @moz-todo',
289        lambda m: '%stodo%s'
290            % (m.group(1), m.group(2))
291        , code)
292
293    code = re.sub(r'((?:^|\n|;)\s*)(is.*;) @moz-todo',
294        lambda m: '%stodo_%s'
295            % (m.group(1), m.group(2))
296        , code)
297
298    code = re.sub(r'@moz-UniversalBrowserRead;',
299        "netscape.security.PrivilegeManager.enablePrivilege('UniversalBrowserRead');"
300        , code)
301
302    code = code.replace('../images/', 'image_')
303
304    assert '@' not in code, '@ not in code:\n%s' % code
305
306    return code
307
308used_tests = {}
309for i in range(len(tests)):
310    test = tests[i]
311
312    name = test['name']
313    print "\r(%s)" % name, " "*32, "\t",
314
315    if name in used_tests:
316        print "Test %s is defined twice" % name
317    used_tests[name] = 1
318
319    mapped_name = None
320    for mn in sorted(name_mapping.keys(), key=len, reverse=True):
321        if name.startswith(mn):
322            mapped_name = "%s/%s" % (name_mapping[mn], name)
323            break
324    if not mapped_name:
325        print "LIKELY ERROR: %s has no defined target directory mapping" % name
326        mapped_name = name
327    if 'manual' in test:
328        mapped_name += "-manual"
329
330    cat_total = ''
331    for cat_part in [''] + name.split('.')[:-1]:
332        cat_total += cat_part+'.'
333        if not cat_total in category_names: category_names.append(cat_total)
334        category_contents_all.setdefault(cat_total, []).append(name)
335    category_contents_direct.setdefault(cat_total, []).append(name)
336
337    for ref in test.get('testing', []):
338        if ref not in spec_ids:
339            print "Test %s uses nonexistent spec point %s" % (name, ref)
340        spec_refs.setdefault(ref, []).append(name)
341    #if not (len(test.get('testing', [])) or 'mozilla' in test):
342    if not test.get('testing', []):
343        print "Test %s doesn't refer to any spec points" % name
344
345    if test.get('expected', '') == 'green' and re.search(r'@assert pixel .* 0,0,0,0;', test['code']):
346        print "Probable incorrect pixel test in %s" % name
347
348    code = expand_test_code(test['code'])
349
350    mochitest = not (W3CMODE or 'manual' in test or 'disabled' in test.get('mozilla', {}))
351    if mochitest:
352        mochi_code = expand_mochitest_code(test['code'])
353
354        mochi_name = name
355        if 'mozilla' in test:
356            if 'throws' in test['mozilla']:
357                mochi_code = templates['mochitest.exception'] % mochi_code
358            if 'bug' in test['mozilla']:
359                mochi_name = "%s - bug %s" % (name, test['mozilla']['bug'])
360
361        if 'desc' in test:
362            mochi_desc = '<!-- Testing: %s -->\n' % test['desc']
363        else:
364            mochi_desc = ''
365
366        if 'deferTest' in mochi_code:
367            mochi_setup = ''
368            mochi_footer = ''
369        else:
370            mochi_setup = ''
371            mochi_footer = 'SimpleTest.finish();\n'
372
373        for f in ['isPixel', 'todo_isPixel', 'deferTest', 'wrapFunction']:
374            if f in mochi_code:
375                mochi_setup += templates['mochitest.%s' % f]
376    else:
377        if not W3CMODE:
378            print "Skipping mochitest for %s" % name
379        mochi_name = ''
380        mochi_desc = ''
381        mochi_code = ''
382        mochi_setup = ''
383        mochi_footer = ''
384
385    expectation_html = ''
386    if 'expected' in test and test['expected'] is not None:
387        expected = test['expected']
388        expected_img = None
389        if expected == 'green':
390            expected_img = make_flat_image('green-100x50.png', 100, 50, 0,1,0,1)
391            if W3CMODE: expected_img = "/images/" + expected_img
392        elif expected == 'clear':
393            expected_img = make_flat_image('clear-100x50.png', 100, 50, 0,0,0,0)
394            if W3CMODE: expected_img = "/images/" + expected_img
395        else:
396            if ';' in expected: print "Found semicolon in %s" % name
397            expected = re.sub(r'^size (\d+) (\d+)',
398                r'surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, \1, \2)\ncr = cairo.Context(surface)',
399                              expected)
400
401            if mapped_name.endswith("-manual"):
402                png_name = mapped_name[:-len("-manual")]
403            else:
404                png_name = mapped_name
405            expected += "\nsurface.write_to_png('%s/%s.png')\n" % (IMAGEOUTPUTDIR, png_name)
406            eval(compile(expected, '<test %s>' % test['name'], 'exec'), {}, {'cairo':cairo})
407            expected_img = "%s.png" % name
408
409        if expected_img:
410            expectation_html = ('<p class="output expectedtext">Expected output:' +
411                '<p><img src="%s" class="output expected" id="expected" alt="">' % (expected_img))
412
413    canvas = test.get('canvas', 'width="100" height="50"')
414
415    prev = tests[i-1]['name'] if i != 0 else 'index'
416    next = tests[i+1]['name'] if i != len(tests)-1 else 'index'
417
418    name_wrapped = name.replace('.', '.&#8203;') # (see https://bugzilla.mozilla.org/show_bug.cgi?id=376188)
419
420    refs = ''.join('<li><a href="%s/canvas.html#testrefs.%s">%s</a>\n' % (SPECOUTPUTPATH, n,n) for n in test.get('testing', []))
421    if not W3CMODE and 'mozilla' in test and 'bug' in test['mozilla']:
422        refs += '<li><a href="https://bugzilla.mozilla.org/show_bug.cgi?id=%d">Bugzilla</a>' % test['mozilla']['bug']
423
424    notes = '<p class="notes">%s' % test['notes'] if 'notes' in test else ''
425
426    scripts = ''
427    for s in test.get('scripts', []):
428        scripts += '<script src="%s"></script>\n' % (s)
429
430    images = ''
431    for i in test.get('images', []):
432        id = i.split('/')[-1]
433        if '/' not in i:
434            used_images[i] = 1
435            i = '../images/%s' % i
436        images += '<img src="%s" id="%s" class="resource">\n' % (i,id)
437    mochi_images = images.replace('../images/', 'image_')
438    if W3CMODE: images = images.replace("../images/", "/images/")
439
440    fonts = ''
441    fonthack = ''
442    for i in test.get('fonts', []):
443        fonts += '@font-face {\n  font-family: %s;\n  src: url("/fonts/%s.ttf");\n}\n' % (i, i)
444        # Browsers require the font to actually be used in the page
445        if test.get('fonthack', 1):
446            fonthack += '<span style="font-family: %s; position: absolute; visibility: hidden">A</span>\n' % i
447    if fonts:
448        fonts = '<style>\n%s</style>\n' % fonts
449
450    fallback = test.get('fallback', '<p class="fallback">FAIL (fallback content)</p>')
451
452    desc = test.get('desc', '')
453    escaped_desc = simpleEscapeJS(desc)
454    template_params = {
455        'name':name, 'name_wrapped':name_wrapped, 'backrefs':backref_html(name),
456        'mapped_name':mapped_name,
457        'desc':desc, 'escaped_desc':escaped_desc,
458        'prev':prev, 'next':next, 'refs':refs, 'notes':notes, 'images':images,
459        'fonts':fonts, 'fonthack':fonthack,
460        'canvas':canvas, 'expected':expectation_html, 'code':code, 'scripts':scripts,
461        'mochi_name':mochi_name, 'mochi_desc':mochi_desc, 'mochi_code':mochi_code,
462        'mochi_setup':mochi_setup, 'mochi_footer':mochi_footer, 'mochi_images':mochi_images,
463        'fallback':fallback
464    }
465
466    if W3CMODE:
467        f = codecs.open('%s/%s.html' % (TESTOUTPUTDIR, mapped_name), 'w', 'utf-8')
468        f.write(templates['w3c'] % template_params)
469    else:
470        f = codecs.open('%s/%s.html' % (TESTOUTPUTDIR, name), 'w', 'utf-8')
471        f.write(templates['standalone'] % template_params)
472
473        f = codecs.open('%s/framed.%s.html' % (TESTOUTPUTDIR, name), 'w', 'utf-8')
474        f.write(templates['framed'] % template_params)
475
476        f = codecs.open('%s/minimal.%s.html' % (TESTOUTPUTDIR, name), 'w', 'utf-8')
477        f.write(templates['minimal'] % template_params)
478
479    if mochitest:
480        mochitests.append(name)
481        f = codecs.open('%s/mochitests/test_%s.html' % (MISCOUTPUTDIR, name), 'w', 'utf-8')
482        f.write(templates['mochitest'] % template_params)
483
484def write_mochitest_makefile():
485    f = open('%s/mochitests/Makefile.in' % MISCOUTPUTDIR, 'w')
486    f.write(templates['mochitest.Makefile'])
487    files = ['test_%s.html' % n for n in mochitests] + ['image_%s' % n for n in used_images]
488    chunksize = 100
489    chunks = []
490    for i in range(0, len(files), chunksize):
491        chunk = files[i:i+chunksize]
492        name = '_TEST_FILES_%d' % (i / chunksize)
493        chunks.append(name)
494        f.write('%s = \\\n' % name)
495        for file in chunk: f.write('\t%s \\\n' % file)
496        f.write('\t$(NULL)\n\n')
497    f.write('# split up into groups to work around command-line length limits\n')
498    for name in chunks:
499        f.write('libs:: $(%s)\n\t$(INSTALL) $(foreach f,$^,"$f") $(DEPTH)/_tests/testing/mochitest/tests/$(relativesrcdir)\n\n' % name)
500
501if not W3CMODE:
502    for i in used_images:
503        shutil.copyfile("../../images/%s" % i, "%s/mochitests/image_%s" % (MISCOUTPUTDIR, i))
504    write_mochitest_makefile()
505
506print
507
508def write_index():
509    f = open('%s/index.html' % TESTOUTPUTDIR, 'w')
510    f.write(templates['index.w3c' if W3CMODE else 'index'] % { 'updated':time.strftime('%Y-%m-%d', time.gmtime()) })
511    f.write('\n<ul class="testlist">\n')
512    depth = 1
513    for category in category_names:
514        name = category[1:-1] or ''
515        count = len(category_contents_all[category])
516        new_depth = category.count('.')
517        while new_depth < depth: f.write(' '*(depth-1) + '</ul>\n'); depth -= 1
518        f.write(' '*depth + templates['index.w3c.category.item' if W3CMODE else 'index.category.item'] % (name or 'all', name, count, '' if count==1 else 's'))
519        while new_depth+1 > depth: f.write(' '*depth + '<ul>\n'); depth += 1
520        for item in category_contents_direct.get(category, []):
521            f.write(' '*depth + '<li><a href="%s.html">%s</a>\n' % (item, item) )
522    while 0 < depth: f.write(' '*(depth-1) + '</ul>\n'); depth -= 1
523
524def write_category_indexes():
525    for category in category_names:
526        name = (category[1:-1] or 'all')
527
528        f = open('%s/index.%s.html' % (TESTOUTPUTDIR, name), 'w')
529        f.write(templates['index.w3c.frame' if W3CMODE else 'index.frame'] % { 'backrefs':backref_html(name), 'category':name })
530        for item in category_contents_all[category]:
531            f.write(templates['index.w3c.frame.item' if W3CMODE else 'index.frame.item'] % item)
532
533def write_reportgen():
534    f = open('%s/reportgen.html' % MISCOUTPUTDIR, 'w')
535    items_text = ',\n'.join(('"%s"' % item) for item in category_contents_all['.'])
536    f.write(templates['reportgen'] % {'items':items_text })
537
538def write_results():
539    results = {}
540    uas = []
541    uastrings = {}
542    for item in category_contents_all['.']: results[item] = {}
543
544    f = open('%s/results.html' % MISCOUTPUTDIR, 'w')
545    f.write(templates['results'])
546
547    if not os.path.exists('results.yaml'):
548        print "Can't find results.yaml"
549    else:
550        for resultset in yaml.load(open('results.yaml', "r").read()):
551            #title = "%s (%s)" % (resultset['ua'], resultset['time'])
552            title = resultset['name']
553            #assert title not in uas # don't allow repetitions
554            if title not in uas:
555                uas.append(title)
556                uastrings[title] = resultset['ua']
557            else:
558                assert uastrings[title] == resultset['ua']
559            for r in resultset['results']:
560                if r['id'] not in results:
561                    print 'Skipping results for removed test %s' % r['id']
562                    continue
563                results[r['id']][title] = (
564                        r['status'].lower(),
565                        re.sub(r'%(..)', lambda m: chr(int(m.group(1), 16)),
566                        re.sub(r'%u(....)', lambda m: unichr(int(m.group(1), 16)),
567                            r['notes'])).encode('utf8')
568                        )
569
570    passes = {}
571    for ua in uas:
572        f.write('<th title="%s">%s\n' % (uastrings[ua], ua))
573        passes[ua] = 0
574    for id in category_contents_all['.']:
575        f.write('<tr><td><a href="#%s" id="%s">#</a> <a href="%s.html">%s</a>\n' % (id, id, id, id))
576        for ua in uas:
577            status, details = results[id].get(ua, ('', ''))
578            f.write('<td class="r %s"><ul class="d">%s</ul>\n' % (status, details))
579            if status == 'pass': passes[ua] += 1
580    f.write('<tr><th>Passes\n')
581    for ua in uas:
582        f.write('<td>%.1f%%\n' % ((100.0 * passes[ua]) / len(category_contents_all['.'])))
583    f.write('<tr><td>\n')
584    for ua in uas:
585        f.write('<td>%s\n' % ua)
586    f.write('</table>\n')
587
588def getNodeText(node):
589    t, offsets = '', []
590
591    # Skip over any previous annotations we added
592    if node.nodeType == node.ELEMENT_NODE and 'testrefs' in node.getAttribute('class').split(' '):
593        return t, offsets
594
595    if node.nodeType == node.TEXT_NODE:
596        val = node.nodeValue
597        val = val.replace(unichr(0xa0), ' ') # replace &nbsp;s
598        t += val
599        offsets += [ (node, len(node.nodeValue)) ]
600    for n in node.childNodes:
601        child_t, child_offsets = getNodeText(n)
602        t += child_t
603        offsets += child_offsets
604    return t, offsets
605
606def htmlSerializer(element):
607    element.normalize()
608    rv = []
609    specialtext = ['style', 'script', 'xmp', 'iframe', 'noembed', 'noframes', 'noscript']
610    empty = ['area', 'base', 'basefont', 'bgsound', 'br', 'col', 'embed', 'frame',
611             'hr', 'img', 'input', 'link', 'meta', 'param', 'spacer', 'wbr']
612
613    def serializeElement(element):
614        if element.nodeType == Node.DOCUMENT_TYPE_NODE:
615            rv.append("<!DOCTYPE %s>" % element.name)
616        elif element.nodeType == Node.DOCUMENT_NODE:
617            for child in element.childNodes:
618                serializeElement(child)
619        elif element.nodeType == Node.COMMENT_NODE:
620            rv.append("<!--%s-->" % element.nodeValue)
621        elif element.nodeType == Node.TEXT_NODE:
622            unescaped = False
623            n = element.parentNode
624            while n is not None:
625                if n.nodeName in specialtext:
626                    unescaped = True
627                    break
628                n = n.parentNode
629            if unescaped:
630                rv.append(element.nodeValue)
631            else:
632                rv.append(escapeHTML(element.nodeValue))
633        else:
634            rv.append("<%s" % element.nodeName)
635            if element.hasAttributes():
636                for name, value in element.attributes.items():
637                    rv.append(' %s="%s"' % (name, escapeHTML(value)))
638            rv.append(">")
639            if element.nodeName not in empty:
640                for child in element.childNodes:
641                    serializeElement(child)
642                rv.append("</%s>" % element.nodeName)
643    serializeElement(element)
644    return '<!DOCTYPE html>\n' + ''.join(rv)
645
646def write_annotated_spec():
647    # Load the stripped-down XHTMLised copy of the spec
648    doc = xml.dom.minidom.parse(open('current-work-canvas.xhtml', 'r'))
649
650    # Insert our new stylesheet
651    n = doc.getElementsByTagName('head')[0].appendChild(doc.createElement('link'))
652    n.setAttribute('rel', 'stylesheet')
653    n.setAttribute('href', '../common/canvas-spec.css' if W3CMODE else '../spectest.css')
654    n.setAttribute('type', 'text/css')
655
656    spec_assertion_patterns = []
657    for a in spec_assertions:
658        # Warn about problems
659        if a['id'] not in spec_refs:
660            print "Unused spec statement %s" % a['id']
661
662        pattern_text = a['text']
663
664        if 'keyword' in a:
665            # Explicit keyword override
666            keyword = a['keyword']
667        else:
668            # Extract the marked keywords, and remove the markers
669            keyword = 'none'
670            for kw in ['must', 'should', 'required']:
671                if ('*%s*' % kw) in pattern_text:
672                    keyword = kw
673                    pattern_text = pattern_text.replace('*%s*' % kw, kw)
674                    break
675        # Make sure there wasn't >1 keyword
676        for kw in ['must', 'should', 'required']:
677            assert('*%s*' % kw not in pattern_text)
678
679        # Convert the special pattern format into regexp syntax
680        pattern_text = (pattern_text.
681            # Escape relevant characters
682            replace('*', r'\*').
683            replace('+', r'\+').
684            replace('.', r'\.').
685            replace('(', r'\(').
686            replace(')', r'\)').
687            replace('[', r'\[').
688            replace(']', r'\]').
689            # Convert special sequences back into unescaped regexp code
690            replace(' ', r'\s+').
691            replace(r'<\.\.\.>', r'.+').
692            replace('<^>', r'()').
693            replace('<eol>', r'\s*?\n')
694        )
695        pattern = re.compile(pattern_text, re.S)
696        spec_assertion_patterns.append( (a['id'], pattern, keyword, a.get('previously', None)) )
697    matched_assertions = {}
698
699    def process_element(e):
700        if e.nodeType == e.ELEMENT_NODE and (e.getAttribute('class') == 'impl' or e.hasAttribute('data-component')):
701            for c in e.childNodes:
702                process_element(c)
703            return
704
705        t, offsets = getNodeText(e)
706        for id, pattern, keyword, previously in spec_assertion_patterns:
707            m = pattern.search(t)
708            if m:
709                # When the pattern-match isn't enough to uniquely identify a sentence,
710                # allow explicit back-references to earlier paragraphs
711                if previously:
712                    if len(previously) >= 3:
713                        n, text, exp = previously
714                    else:
715                        n, text = previously
716                        exp = True
717                    node = e
718                    while n and node.previousSibling:
719                        node = node.previousSibling
720                        n -= 1
721                    if (text not in getNodeText(node)[0]) == exp:
722                        continue # discard this match
723
724                if id in matched_assertions:
725                    print "Spec statement %s matches multiple places" % id
726                matched_assertions[id] = True
727
728                if m.lastindex != 1:
729                    print "Spec statement %s has incorrect number of match groups" % id
730
731                end = m.end(1)
732                end_node = None
733                for end_node, o in offsets:
734                    if end < o:
735                        break
736                    end -= o
737                assert(end_node)
738
739                n1 = doc.createElement('span')
740                n1.setAttribute('class', 'testrefs kw-%s' % keyword)
741                n1.setAttribute('id', 'testrefs.%s' % id)
742                n1.appendChild(doc.createTextNode(' '))
743
744                n = n1.appendChild(doc.createElement('a'))
745                n.setAttribute('href', '#testrefs.%s' % id)
746                n.setAttribute('title', id)
747                n.appendChild(doc.createTextNode('#'))
748
749                n1.appendChild(doc.createTextNode(' '))
750                for test_id in spec_refs.get(id, []):
751                    n = n1.appendChild(doc.createElement('a'))
752                    n.setAttribute('href', '../canvas/%s.html' % test_id)
753                    n.appendChild(doc.createTextNode(test_id))
754                    n1.appendChild(doc.createTextNode(' '))
755                n0 = doc.createTextNode(end_node.nodeValue[:end])
756                n2 = doc.createTextNode(end_node.nodeValue[end:])
757
758                p = end_node.parentNode
759                p.replaceChild(n2, end_node)
760                p.insertBefore(n1, n2)
761                p.insertBefore(n0, n1)
762
763                t, offsets = getNodeText(e)
764
765    for e in doc.getElementsByTagName('body')[0].childNodes:
766        process_element(e)
767
768    for s in spec_assertions:
769        if s['id'] not in matched_assertions:
770            print "Annotation incomplete: Unmatched spec statement %s" % s['id']
771
772    # Convert from XHTML back to HTML
773    doc.documentElement.removeAttribute('xmlns')
774    doc.documentElement.setAttribute('lang', doc.documentElement.getAttribute('xml:lang'))
775
776    head = doc.documentElement.getElementsByTagName('head')[0]
777    head.insertBefore(doc.createElement('meta'), head.firstChild).setAttribute('charset', 'UTF-8')
778
779    f = codecs.open('%s/canvas.html' % SPECOUTPUTDIR, 'w', 'utf-8')
780    f.write(htmlSerializer(doc))
781
782if not W3CMODE:
783    write_index()
784    write_category_indexes()
785    write_reportgen()
786    write_results()
787    write_annotated_spec()
788