source: applications/WOFFValidator/woffValidator.py @ 622

Revision 622, 64.8 KB checked in by tal, 4 years ago (diff)
Basic private data testing and display.
Line 
1"""
2This is a command line tool for validating the file structure
3of WOFF files. It is not yet complete!
4"""
5
6license ="""The MIT License
7
8Copyright (c) 2009 Type Supply LLC
9
10Permission is hereby granted, free of charge, to any person obtaining a copy
11of this software and associated documentation files (the "Software"), to deal
12in the Software without restriction, including without limitation the rights
13to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14copies of the Software, and to permit persons to whom the Software is
15furnished to do so, subject to the following conditions:
16
17The above copyright notice and this permission notice shall be included in
18all copies or substantial portions of the Software.
19
20THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
26THE SOFTWARE.
27"""
28
29import os
30import sys
31import struct
32import zlib
33import numpy
34import sstruct
35from xml.etree import ElementTree
36from xml.parsers.expat import ExpatError
37from fontTools.ttLib.sfnt import getSearchRange, SFNTDirectoryEntry, \
38    sfntDirectoryFormat, sfntDirectorySize, sfntDirectoryEntryFormat, sfntDirectoryEntrySize
39
40# ------
41# Header
42# ------
43
44headerFormat = """
45    > # big endian
46    signature:      4s
47    flavor:         4s
48    length:         l
49    numTables:      H
50    reserved:       H
51    totalSfntSize:  l
52    majorVersion:   H
53    minorVersion:   H
54    metaOffset:     l
55    metaLength:     l
56    metaOrigLength: l
57    privOffset:     l
58    privLength:     l
59"""
60headerSize = sstruct.calcsize(headerFormat)
61
62def testHeaderSize(data, reporter):
63    """
64    Tests:
65    - length of file is long enough to contain header.
66    """
67    if len(data) < headerSize:
68        reporter.logError(message="The header is not the proper length.")
69        return True
70    else:
71        reporter.logPass(message="The header length is correct.")
72
73def testHeaderStructure(data, reporter):
74    """
75    Tests:
76    - header structure by trying to unpack header.
77    """
78    try:
79        sstruct.unpack2(headerFormat, data)
80        reporter.logPass(message="The header structure is correct.")
81    except:
82        reporter.logError(message="The header is not properly structured.")
83        return True
84
85def testHeaderSignature(data, reporter):
86    """
87    Tests:
88    - signature is "wOFF"
89    """
90    header = unpackHeader(data)
91    signature = header["signature"]
92    if signature != "wOFF":
93        reporter.logError(message="Invalid signature: %s." % signature)
94        return True
95    else:
96        reporter.logPass(message="The signature is correct.")
97
98def testHeaderFlavor(data, reporter):
99    """
100    Tests:
101    - flavor is OTTO, 0x00010000 or true.
102    - if flavor is OTTO, CFF is present.
103    - if flavor is not OTTO, CFF is not present.
104    - flavor could not be validated because the directory could not be unpacked.
105    """
106    header = unpackHeader(data)
107    flavor = header["flavor"]
108    if flavor not in ("OTTO", "\000\001\000\000", "true"):
109        reporter.logError(message="Unknown flavor: %s." % flavor)
110    else:
111        try:
112            tags = [table["tag"] for table in unpackDirectory(data)]
113            if "CFF " in tags and flavor != "OTTO":
114                reporter.logError(message="A \"CFF\" table is defined in the font and the flavor is not set to \"OTTO\".")
115            elif "CFF " not in tags and flavor == "OTTO":
116                reporter.logError(message="The flavor is set to \"OTTO\" but no \"CFF\" table is defined.")
117            else:
118                reporter.logPass(message="The flavor is a correct value.")
119        except:
120            reporter.logWarning(message="Could not validate the flavor.")
121
122def testHeaderLength(data, reporter):
123    """
124    Tests:
125    - length of data matches defined length.
126    - length of data is long enough for header and directory for defined number of tables.
127    - length of data is long enough to contain table lengths defined in the directory,
128      metaLength and privLength.
129    """
130    header = unpackHeader(data)
131    length = header["length"]
132    numTables = header["numTables"]
133    minLength = headerSize + (directorySize * numTables)
134    if length != len(data):
135        reporter.logError(message="Defined length (%d) does not match actual length (%d)." % (length, len(data)))
136        return True
137    if length < minLength:
138        reporter.logError(message="Invalid length defined (%d) for number of tables defined." % length)
139        return True
140    directory = unpackDirectory(data)
141    for entry in directory:
142        compLength = entry["compLength"]
143        if compLength % 4:
144            compLength += 4 - (compLength % 4)
145        minLength += compLength
146    metaLength = header["metaLength"]
147    privLength = header["privLength"]
148    if privLength and metaLength % 4:
149        metaLength += 4 - (metaLength % 4)
150    minLength += metaLength + privLength
151    if length < minLength:
152        reporter.logError(message="Defined length (%d) is not long enough to contain defined lengths (%d)." % (length, minLength))
153        return True
154    reporter.logPass(message="The length defined in the header is correct.")
155
156def testHeaderReserved(data, reporter):
157    """
158    Tests:
159    - reserved is 0
160    """
161    header = unpackHeader(data)
162    reserved = header["reserved"]
163    if reserved != 0:
164        reporter.logError(message="Invalid value in reserved field (%d)." % reserved)
165        return True
166    else:
167        reporter.logPass(message="The value in the reserved field is correct.")
168
169def testHeaderTotalSFNTSize(data, reporter):
170    """
171    Tests:
172    - origLength values in the directory, with proper padding,
173      sum to the totalSfntSize in the header.
174    """
175    header = unpackHeader(data)
176    directory = unpackDirectory(data)
177    totalSfntSize = header["totalSfntSize"]
178    numTables = header["numTables"]
179    requiredSize = sfntDirectorySize + (numTables * sfntDirectoryEntrySize)
180    for table in directory:
181        origLength = table["origLength"]
182        if origLength % 4:
183            origLength += 4 - (origLength % 4)
184        requiredSize += origLength
185    if totalSfntSize != requiredSize:
186        reporter.logError(message="The total sfnt size (%d) does not match the required sfnt size (%d)." % (totalSfntSize, requiredSize))
187    else:
188        reporter.logPass(message="The total sfnt size is valid.")
189
190def testHeaderMajorVersionAndMinorVersion(data, reporter):
191    """
192    Tests:
193    - major version + minor version > 1.0
194    """
195    header = unpackHeader(data)
196    majorVersion = header["majorVersion"]
197    minorVersion = header["minorVersion"]
198    version = "%d.%d" % (majorVersion, minorVersion)
199    if float(version) < 1.0:
200        reporter.logWarning(message="The major version (%d) and minor version (%d) create a version (%s) less than 1.0." % (majorVersion, minorVersion, version))
201    else:
202        reporter.logPass(message="The major version and minor version are valid numbers.")
203
204
205# ---------------
206# Table Directory
207# ---------------
208
209directoryFormat = """
210    > # big endian
211    tag:            4s
212    offset:         l
213    compLength:     l
214    origLength:     l
215    origChecksum:   l
216"""
217directorySize = sstruct.calcsize(directoryFormat)
218
219def testHeaderNumTables(data, reporter):
220    """
221    Tests:
222    - numTables in header is at least 1.
223    - the number of tables defined in the header can be successfully unpacked.
224    """
225    header = unpackHeader(data)
226    numTables = header["numTables"]
227    if numTables < 1:
228        reporter.logError(message="Invalid number of tables defined in header structure (%d)." % numTables)
229        return True
230    data = data[headerSize:]
231    for index in range(numTables):
232        try:
233            d, data = sstruct.unpack2(directoryFormat, data)
234        except:
235            reporter.logError(message="The defined number of tables in the header (%d) does not match the actual number of tables (%d)." % (numTables, index))
236            return True
237    reporter.logPass(message="The number of tables defined in the header is valid.")
238
239def testDirectoryTableOrder(data, reporter):
240    """
241    Tests:
242    - directory in ascending order based on tag.
243    """
244    storedOrder = [table["tag"] for table in unpackDirectory(data)]
245    if storedOrder != sorted(storedOrder):
246        reporter.logError(message="The table directory entries are not stored in alphabetical order.")
247    else:
248        reporter.logPass(message="The table directory entries are stored in the proper order.")
249
250def testDirectoryBorders(data, reporter):
251    """
252    Tests:
253    - table offset is before the end of the header/directory.
254    - table offset is after the end of the file.
255    - table offset + length is greater than the available length.
256    - table length is longer than the available length.
257    """
258    header = unpackHeader(data)
259    totalLength = header["length"]
260    numTables = header["numTables"]
261    minOffset = headerSize + (directorySize * numTables)
262    maxLength = totalLength - minOffset
263    directory = unpackDirectory(data)
264    shouldStop = False
265    for table in directory:
266        tag = table["tag"]
267        offset = table["offset"]
268        length = table["compLength"]
269        offsetErrorMessage = "The \"%s\" table directory entry has an invalid offset (%d)." % (tag, offset)
270        lengthErrorMessage = "The \"%s\" table directory entry has an invalid length (%d)." % (tag, length)
271        haveError = False
272        if offset < minOffset:
273            reporter.logError(message=offsetErrorMessage)
274            haveError = True
275        elif offset > totalLength:
276            reporter.logError(message=offsetErrorMessage)
277            haveError = True
278        elif (offset + length) > totalLength:
279            reporter.logError(message=lengthErrorMessage)
280            haveError = True
281        elif length > maxLength:
282            reporter.logError(message=lengthErrorMessage)
283            haveError = True
284        if haveError:
285            shouldStop = True
286        else:
287            reporter.logPass(message="The \"%s\" table directory entry has a valid offset and length." % tag)
288    if shouldStop:
289        return True
290
291def testDirectoryCompressedLength(data, reporter):
292    """
293    Tests:
294    - compLength must be less than or equal to origLength
295    """
296    directory = unpackDirectory(data)
297    for table in directory:
298        tag = table["tag"]
299        compLength = table["compLength"]
300        origLength = table["origLength"]
301        if compLength > origLength:
302            reporter.logError(message="The \"%s\" table directory entry has an compressed length (%d) lager than the original length (%d)." % (tag, compLength, origLength))
303        else:
304            reporter.logPass(message="The \"%s\" table directory entry has poper compLength and origLength values." % tag)
305
306def testDirectoryDecompressedLength(data, reporter):
307    """
308    Tests:
309    - decompressed length matches origLength
310    """
311    directory = unpackDirectory(data)
312    tableData = unpackTableData(data)
313    for table in directory:
314        tag = table["tag"]
315        offset = table["offset"]
316        compLength = table["compLength"]
317        origLength = table["origLength"]
318        if compLength >= origLength:
319            continue
320        decompressedData = tableData[tag]
321        decompressedLength = len(decompressedData)
322        if origLength != decompressedLength:
323            reporter.logError(message="The \"%s\" table directory entry has an original length (%d) that does not match the actual length of the decompressed data (%d)." % (tag, origLength, decompressedLength))
324        else:
325            reporter.logPass(message="The \"%s\" table directory entry has a proper original length compared to the actual decompressed data." % tag)
326
327def testDirectoryChecksums(data, reporter):
328    """
329    Tests:
330    - checksum for table data, decompressed if necessary, matched
331      the checkSum defined in the directory entry.
332    """
333    directory = unpackDirectory(data)
334    tables = unpackTableData(data)
335    for entry in directory:
336        tag = entry["tag"]
337        origChecksum = entry["origChecksum"]
338        newChecksum = calcChecksum(tag, tables[tag])
339        if newChecksum != origChecksum:
340            reporter.logError(message="The \"%s\" table directory entry original checksum (%d) does not match the checksum (%d) calculated from the data." % (tag, origChecksum, newChecksum))
341        else:
342            reporter.logPass(message="The \"%s\" table directory entry original checksum is correct." % tag)
343
344
345# ------
346# Tables
347# ------
348
349def testTableDataStart(data, reporter):
350    """
351    Tests:
352    - table data starts immediately after the directory.
353    """
354    header = unpackHeader(data)
355    directory = unpackDirectory(data)
356    requiredStart = headerSize + (directorySize * header["numTables"])
357    offsets = [entry["offset"] for entry in directory]
358    start = min(offsets)
359    if requiredStart != start:
360        reporter.logError(message="The table data does not start (%d) in the required position (%d)." % (start, requiredStart))
361    else:
362        reporter.logPass(message="The table data begins in the proper position.")
363
364def testTablePadding(data, reporter):
365    """
366    Tests:
367    - table offsets are on four byte boundaries
368    - final table ends on a four byte boundary.
369        - if metadata or private data is present, use first offset.
370        - if no metadata or pivate data is present, use end of file.
371    """
372    header = unpackHeader(data)
373    directory = unpackDirectory(data)
374    # test offset positions
375    for table in directory:
376        tag = table["tag"]
377        offset = table["offset"]
378        if offset % 4:
379            reporter.logError(message="The \"%s\" table does not begin on a 4-byte boundary." % tag)
380        else:
381            reporter.logPass(message="The \"%s\" table begins on a proper 4-byte boundary." % tag)
382    # test final table
383    endError = False
384    if header["metaOffset"] == 0 and header["privOffset"] == 0:
385        if header["length"] % 4:
386            endError = True
387    else:
388        if header["metaOffset"] != 0:
389            sfntEnd = header["metaOffset"]
390        else:
391            sfntEnd = header["privOffset"]
392        if sfntEnd % 4:
393            endError = True
394    if endError:
395        reporter.logError(message="The sfnt data does not end with proper padding.")
396    else:
397        reporter.logPass(message="The sfnt data ends with proper padding.")
398
399def testTableDecompression(data, reporter):
400    """
401    Tests:
402    - the data for entries where compLength < origLength can be successfully decompressed.
403    """
404    shouldStop = False
405    for table in unpackDirectory(data):
406        tag = table["tag"]
407        offset = table["offset"]
408        compLength = table["compLength"]
409        origLength = table["origLength"]
410        if origLength <= compLength:
411            continue
412        entryData = data[offset:offset+compLength]
413        try:
414            decompressed = zlib.decompress(entryData)
415            reporter.logPass(message="The \"%s\" table data can be decompressed with zlib." % tag)
416        except zlib.error:
417            shouldStop = True
418            reporter.logError(message="The \"%s\" table data can not be decompressed with zlib." % tag)
419    return shouldStop
420
421def testHeadCheckSumAdjustment(data, reporter):
422    """
423    Tests:
424    - Missing head table.
425    - head table with a structure that can not be parsed.
426    - head checkSumAdjustment that does not match the computed value for the sfnt data.
427    """
428    tables = unpackTableData(data)
429    if "head" not in tables:
430        reporter.logWarning(message="The font does not contain a \"head\" table.")
431        return
432    newChecksum = calcHeadCheckSum(data)
433    data = tables["head"]
434    try:
435        format = ">l"
436        checksum = struct.unpack(format, data[8:12])[0]
437        if checksum != newChecksum:
438            reporter.logError(message="The \"head\" table checkSumAdjustment (%d) does not match the calculated checkSumAdjustment (%d)." % (checksum, newChecksum))
439        else:
440            reporter.logPass(message="The \"head\" table checkSumAdjustment is valid.")
441    except:
442        reporter.logError(message="The \"head\" table is not properly structured.")
443
444def testDSIG(data, reporter):
445    """
446    Tests:
447    - warn if DSIG is present
448    """
449    directory = unpackDirectory(data)
450    for entry in directory:
451        if entry["tag"] == "DSIG":
452            reporter.logWarning(
453                message="The font contains a \"DSIG\" table. This can not be validated by this tool.",
454                information="If you need this functionality, contact the developer of this tool.")
455            return
456    reporter.logNote(message="The font does not contain a \"DSIG\" table.")
457
458
459# --------
460# Metadata
461# --------
462
463def shouldSkipMetadataTest(data, reporter):
464    """
465    This is used at the start of metadata test functions.
466    It writes a note and returns True if not metadata exists.
467    """
468    header = unpackHeader(data)
469    metaOffset = header["metaOffset"]
470    metaLength = header["metaLength"]
471    if metaOffset == 0 or metaLength == 0:
472        reporter.logNote(message="No metadata to test.")
473        return True
474
475def testMetadataOffsetAndLength(data, reporter):
476    """
477    Tests:
478    - if offset is zero, length is 0. vice-versa.
479    - offset is before the end of the header/directory.
480    - offset is after the end of the file.
481    - offset + length is greater than the available length.
482    - length is longer than the available length.
483    - offset begins immediately after last table.
484    - offset begins on 4-byte boundary.
485    """
486    header = unpackHeader(data)
487    metaOffset = header["metaOffset"]
488    metaLength = header["metaLength"]
489    # empty offset or length
490    if metaOffset == 0 or metaLength == 0:
491        if metaOffset == 0 and metaLength == 0:
492            reporter.logPass(message="The length and offset are appropriately set for empty metadata.")
493        else:
494            reporter.logError(message="The metadata offset (%d) and metadata length (%d) are not properly set. If one is 0, they both must be 0." % (metaOffset, metaLength))
495        return
496    # 4-byte boundary
497    if metaOffset % 4:
498        reporter.logError(message="The metadata does not begin on a four-byte boundary.")
499        return
500    # borders
501    totalLength = header["length"]
502    numTables = header["numTables"]
503    directory = unpackDirectory(data)
504    offsets = [headerSize + (directorySize * numTables)]
505    for table in directory:
506        tag = table["tag"]
507        offset = table["offset"]
508        length = table["compLength"]
509        offsets.append(offset + length)
510    minOffset = max(offsets)
511    if minOffset % 4:
512        minOffset += 4 - (minOffset % 4)
513    maxLength = totalLength - minOffset
514    offsetErrorMessage = "The metadata has an invalid offset (%d)." % metaOffset
515    lengthErrorMessage = "The metadata has an invalid length (%d)." % metaLength
516    if metaOffset < minOffset:
517        reporter.logError(message=offsetErrorMessage)
518    elif metaOffset > totalLength:
519        reporter.logError(message=offsetErrorMessage)
520    elif (metaOffset + metaLength) > totalLength:
521        reporter.logError(message=lengthErrorMessage)
522    elif metaLength > maxLength:
523        reporter.logError(message=lengthErrorMessage)
524    elif metaOffset != minOffset:
525        reporter.logError(message=offsetErrorMessage)
526    else:
527        reporter.logPass(message="The metadata has properly set offset and length.")
528
529def testMetadataDecompression(data, reporter):
530    """
531    Tests:
532    - metadata can be decompressed with zlib.
533    """
534    if shouldSkipMetadataTest(data, reporter):
535        return
536    compData = unpackMetadata(data, decompress=False, parse=False)
537    try:
538        zlib.decompress(compData)
539    except zlib.error:
540        reporter.logError(message="The metdata can not be decompressed with zlib.")
541        return True
542    reporter.logPass(message="The metadata can be decompressed with zlib.")
543
544def testMetadataDecompressedLength(data, reporter):
545    """
546    Tests:
547    - decompressed metadata length matches metaOrigLength
548    """
549    if shouldSkipMetadataTest(data, reporter):
550        return
551    header = unpackHeader(data)
552    metadata = unpackMetadata(data, parse=False)
553    metaOrigLength = header["metaOrigLength"]
554    decompressedLength = len(metadata)
555    if metaOrigLength != decompressedLength:
556        reporter.logError(message="The decompressed metadata length (%d) does not match the original metadata length (%d) in the header." % (decompressedLength, metaOrigLength))
557    else:
558        reporter.logPass(message="The decompressed metadata length matches the original metadata length in the header.")
559
560def testMetadataParse(data, reporter):
561    """
562    Tests:
563    - metadata can be parsed
564    """
565    if shouldSkipMetadataTest(data, reporter):
566        return
567    metadata = unpackMetadata(data, parse=False)
568    try:
569        tree = ElementTree.fromstring(metadata)
570    except ExpatError:
571        reporter.logError(message="The metadata can not be parsed.")
572        return True
573    reporter.logPass(message="The metadata can be parsed.")
574
575def testMetadataStructure(data, reporter):
576    """
577    Refer to lower level tests.
578    """
579    if shouldSkipMetadataTest(data, reporter):
580        return
581    tree = unpackMetadata(data)
582    testMetadataStructureTopElement(tree, reporter)
583    testMetadataChildElements(tree, reporter)
584
585def testMetadataStructureTopElement(tree, reporter):
586    """
587    Tests:
588    - metadata is top element
589    - version is only attribute of top element
590    - version is 1.0
591    - text in element
592    """
593    haveError = False
594    # metadata as top element
595    if tree.tag != "metadata":
596        reporter.logError("The top element is not \"metadata\".")
597        haveError = True
598    # version as only attribute
599    if tree.attrib.keys() != ["version"]:
600        for key in sorted(tree.attrib.keys()):
601            if key != "version":
602                reporter.logError("Unknown \"%s\" attribute in \"metadata\" element." % key)
603                haveError = True
604    if "version" not in tree.attrib:
605        reporter.logError("The \"version\" attribute is not defined in \"metadata\" element.")
606        haveError = True
607    else:
608        # version is 1.0
609        version = tree.attrib["version"]
610        if version != "1.0":
611            reporter.logError("Invalid value (%s) for \"version\" attribute in \"metadata\" element." % version)
612            haveError = True
613    # text in element
614    if tree.text is not None and tree.text.strip():
615        reporter.logError("Text defined in \"metadata\" element.")
616        haveError = True
617    if not haveError:
618        reporter.logPass("The \"metadata\" element is properly formatted.")
619
620def testMetadataChildElements(tree, reporter):
621    """
622    Tests:
623    - uniqueid is present (warn)
624    - unknown element tags (warn)
625    - known tags are shuttled off to element specific functions
626    """
627    # look for known elements
628    testMetadataElementExistence(tree, reporter)
629    # look for duplicate elements
630    testMetadataDuplicateElements(tree, reporter)
631    # push elements to the appropriate functions
632    optionalElements = "vendor credits description license copyright trademark licensee".split(" ")
633    optionalElements = dict.fromkeys(optionalElements, 0)
634    for element in tree:
635        if element.tag == "uniqueid":
636            testMetadataUniqueid(element, reporter)
637        elif element.tag == "vendor":
638            testMetadataVendor(element, reporter)
639        elif element.tag == "credits":
640            testMetadataCredits(element, reporter)
641        elif element.tag == "description":
642            testMetadataDescription(element, reporter)
643        elif element.tag == "license":
644            testMetadataLicense(element, reporter)
645        elif element.tag == "copyright":
646            testMetadataCopyright(element, reporter)
647        elif element.tag == "trademark":
648            testMetadataTrademark(element, reporter)
649        elif element.tag == "licensee":
650            testMetadataLicensee(element, reporter)
651        else:
652            reporter.logWarning(
653                message="Unknown \"%s\" element." % element.tag,
654                information="This element will be unknown to user agents.")
655
656def testMetadataElementExistence(tree, reporter):
657    """
658    Warn/note missing elements.
659    """
660    foundUniqueid = False
661    tags = "uniqueid vendor credits description license copyright trademark licensee".split(" ")
662    tags = dict.fromkeys(tags, 0)
663    for element in tree:
664        if element.tag not in tags:
665            continue
666        tags[element.tag] += 1
667    # unique id should get a warning
668    if not tags.pop("uniqueid"):
669        reporter.logWarning(message="No \"uniqueid\" child is in the \"metadata\" element.")
670    # others get a note
671    for tag, count in sorted(tags.items()):
672        if count == 0:
673            reporter.logNote(message="No \"%s\" child is in the \"metadata\" element." % tag)
674
675def testMetadataDuplicateElements(tree, reporter):
676    """
677    Look for duplicated, known element tags.
678    """
679    tags = "uniqueid vendor credits description license copyright trademark licensee".split(" ")
680    tags = dict.fromkeys(tags, 0)
681    for element in tree:
682        if element.tag in tags:
683            tags[element.tag] += 1
684    for tag, count in sorted(tags.items()):
685        if count > 1:
686            reporter.logWarning(message="The \"%s\" tag is used more than once in the \"metadata\" element." % tag)
687
688def testMetadataUniqueid(element, reporter):
689    """
690    Tests:
691    - id is present and contains text
692    - unknown attributes
693    - child-elements
694    """
695    required = "id".split(" ")
696    haveError = testMetadataAbstractElement(element, reporter, tag="uniqueid", requiredAttributes=required)
697    if not haveError:
698        reporter.logPass(message="The \"uniqueid\" element is properly formatted.")
699
700def testMetadataVendor(element, reporter):
701    """
702    Tests:
703    - name is present and contains text
704    - url is not present (note)
705    - url is not empty
706    - unknown attributes
707    - child-elements
708    """
709    required = "name".split(" ")
710    optional = "url".split(" ")
711    haveError = testMetadataAbstractElement(element, reporter, tag="vendor", requiredAttributes=required, optionalAttributes=optional)
712    if not haveError:
713        reporter.logPass(message="The \"vendor\" element is properly formatted.")
714
715def testMetadataCredits(element, reporter):
716    """
717    Tests:
718    - no attributes
719    - text
720    - has at least one child element
721    - unknown child elements
722    """
723    haveError = True
724    if testMetadataAbstractElement(element, reporter, tag="vendor", requiredChildElements=["credit"]):
725        haveError = True
726    if not haveError:
727        reporter.logPass(message="The \"credits\" element is properly formatted.")
728    # test credit child elements
729    for child in element:
730        if child.tag == "credit":
731            testMetadataCredit(child, reporter)
732
733def testMetadataCredit(element, reporter):
734    """
735    Tests:
736    - name is present and contains text
737    - url is not present (note)
738    - url is not empty
739    - role is not present (note)
740    - role is not empty
741    - unknown attributes
742    - child-elements
743    """
744    required = "name".split(" ")
745    optional = "url role".split(" ")
746    haveError = testMetadataAbstractElement(element, reporter, tag="credit", requiredAttributes=required, optionalAttributes=optional)
747    if not haveError:
748        reporter.logPass(message="The \"credit\" element is properly formatted.")
749
750def testMetadataDescription(element, reporter):
751    """
752    Tests:
753    - no attributes
754    - no text
755    - has at least one text child element
756    - unknown child elements
757    - text element validity
758    - duplicate languages
759    """
760    haveError = False
761    if testMetadataAbstractElement(element, reporter, tag="description", requiredChildElements=["text"]):
762        haveError = True
763    # validate the text elements
764    if testMetadataAbstractTextElements(element, reporter, "description"):
765        haveError = True
766    # test for duplicate text elements
767    if testMetadataAbstractTextElementLanguages(element, reporter, "description"):
768        haveError = True
769    # test for text element compliance
770    if not haveError:
771        reporter.logPass(message="The \"description\" element is properly formatted.")
772
773def testMetadataLicense(element, reporter):
774    """
775    Tests:
776    - optional attributes
777    - no unknown attributes
778    - no text
779    - has at least one text child element
780    - unknown child elements
781    - text element validity
782    - duplicate languages
783    """
784    optional = "url id".split(" ")
785    haveError = False
786    if testMetadataAbstractElement(element, reporter, tag="license", optionalAttributes=optional, requiredChildElements=["text"]):
787        haveError = True
788    # validate the text elements
789    if testMetadataAbstractTextElements(element, reporter, "license"):
790        haveError = True
791    # test for duplicate text elements
792    if testMetadataAbstractTextElementLanguages(element, reporter, "license"):
793        haveError = True
794    # test for text element compliance
795    if not haveError:
796        reporter.logPass(message="The \"license\" element is properly formatted.")
797
798def testMetadataCopyright(element, reporter):
799    """
800    Tests:
801    - no attributes
802    - no text
803    - has at least one text child element
804    - unknown child elements
805    - text element validity
806    - duplicate languages
807    """
808    haveError = False
809    if testMetadataAbstractElement(element, reporter, tag="copyright", requiredChildElements=["text"]):
810        haveError = True
811    # validate the text elements
812    if testMetadataAbstractTextElements(element, reporter, "copyright"):
813        haveError = True
814    # test for duplicate text elements
815    if testMetadataAbstractTextElementLanguages(element, reporter, "copyright"):
816        haveError = True
817    # test for text element compliance
818    if not haveError:
819        reporter.logPass(message="The \"copyright\" element is properly formatted.")
820
821def testMetadataTrademark(element, reporter):
822    """
823    Tests:
824    - no attributes
825    - no text
826    - has at least one text child element
827    - unknown child elements
828    - text element validity
829    - duplicate languages
830    """
831    haveError = False
832    if testMetadataAbstractElement(element, reporter, tag="trademark", requiredChildElements=["text"]):
833        haveError = True
834    # validate the text elements
835    if testMetadataAbstractTextElements(element, reporter, "trademark"):
836        haveError = True
837    # test for duplicate text elements
838    if testMetadataAbstractTextElementLanguages(element, reporter, "trademark"):
839        haveError = True
840    # test for text element compliance
841    if not haveError:
842        reporter.logPass(message="The \"trademark\" element is properly formatted.")
843
844def testMetadataLicensee(element, reporter):
845    """
846    Tests:
847    - name is present and contains text
848    - unknown attributes
849    - child-elements
850    - text
851    """
852    required = "name".split(" ")
853    haveError = testMetadataAbstractElement(element, reporter, tag="vendor", requiredAttributes=required)
854    if not haveError:
855        reporter.logPass(message="The \"licensee\" element is properly formatted.")
856
857# support
858
859def testMetadataAbstractElement(element, reporter, tag,
860    requiredAttributes=[], optionalAttributes=[], noteMissingOptionalAttributes=True,
861    requiredChildElements=[], requireText=False):
862    haveError = False
863    # missing required attribute
864    if testMetadataAbstractElementRequiredAttributes(element, reporter, tag, requiredAttributes):
865        haveError = True
866    # missing optional attributes
867    testMetadataAbstractElementOptionalAttributes(element, reporter, tag, optionalAttributes, noteMissingOptionalAttributes)
868    # unknown attributes
869    if testMetadataAbstractElementUnknownAttributes(element, reporter, tag, requiredAttributes, optionalAttributes):
870        haveError = True
871    # empty values
872    if testMetadataAbstractElementEmptyValue(element, reporter, tag, requiredAttributes, optionalAttributes):
873        haveError = True
874    # text
875    if requireText:
876        if testMetadataAbstractElementRequiredText(element, reporter, tag):
877            haveError = True
878    else:
879        if testMetadataAbstractElementIllegalText(element, reporter, tag):
880            haveError = True
881    # child elements
882    if requiredChildElements:
883        if testMetadataAbstractElementRequiredChildElements(element, reporter, tag, requiredChildElements):
884            haveError = True
885    else:
886        if testMetadataAbstractElementIllegalChildElements(element, reporter, tag):
887            haveError = True
888    return haveError
889
890def testMetadataAbstractElementRequiredAttributes(element, reporter, tag, requiredAttributes):
891    haveError = False
892    for attr in sorted(requiredAttributes):
893        if attr not in element.attrib:
894            reporter.logError(message="Required attribute \"%s\" is not defined in the \"%s\" element." % (attr, tag))
895            haveError = True
896    return haveError
897
898def testMetadataAbstractElementOptionalAttributes(element, reporter, tag, optionalAttributes, noteMissingOptionalAttributes=True):
899    for attr in sorted(optionalAttributes):
900        if attr not in element.attrib and noteMissingOptionalAttributes:
901            reporter.logNote(message="Optional attribute \"%s\" is not defined in the \"%s\" element." % (attr, tag))
902
903def testMetadataAbstractElementUnknownAttributes(element, reporter, tag, requiredAttributes, optionalAttributes):
904    haveError = False
905    for attr in sorted(element.attrib.keys()):
906        if attr not in requiredAttributes and attr not in optionalAttributes:
907            reporter.logWarning(
908                message="Unknown \"%s\" attribute of \"%s\" element." % (attr, tag),
909                information="This attribute will be unknown to user agents.")
910            haveError = True
911    return haveError
912
913def testMetadataAbstractElementEmptyValue(element, reporter, tag, requiredAttributes, optionalAttributes):
914    haveError = False
915    for attr, value in sorted(element.attrib.items()):
916        # skip unknown attributes
917        if attr not in requiredAttributes and attr not in optionalAttributes:
918            continue
919        # empty value
920        elif not value.strip():
921            reporter.logError(message="The value for the \"%s\" attribute in the \"%s\" element is an empty string." % (attr, tag))
922            haveError = True
923    return haveError
924
925def testMetadataAbstractElementRequiredText(element, reporter, tag):
926    haveError = False
927    if element.text is not None and element.text.strip():
928        pass
929    else:
930        reporter.logError("Text not defined in \"%s\" element." % tag)
931        haveError = True
932    return haveError
933
934def testMetadataAbstractElementIllegalText(element, reporter, tag):
935    haveError = False
936    if element.text is not None and element.text.strip():
937        reporter.logError("Text defined in \"%s\" element." % tag)
938        haveError = True
939    return haveError
940
941def testMetadataAbstractElementRequiredChildElements(element, reporter, tag, requiredChildElements):
942    foundTags = set()
943    for child in element:
944        if child.tag in requiredChildElements:
945            foundTags.add(child.tag)
946        else:
947            reporter.logWarning(
948                message="Unknown \"%s\" child element in \"%s\" element." % (child.tag, tag),
949                information="This element will be unknown to user agents.")
950    haveError = False
951    for childTag in sorted(requiredChildElements):
952        if childTag not in foundTags:
953            reporter.logError(message="Required child element \"%s\" is not defined in the \"%s\" element." % (childTag, tag))
954            haveError = True
955    return haveError
956
957def testMetadataAbstractElementIllegalChildElements(element, reporter, tag):
958    haveError = False
959    if len(element):
960        reporter.logError("Child elements defined in \"%s\" element." % tag)
961        haveError = True
962    return haveError
963
964def testMetadataAbstractTextElements(element, reporter, tag):
965    """
966    Tests:
967    - no unknown attributes
968    - no child elements
969    - optional language
970    - has text
971    """
972    haveError = False
973    for child in element:
974        if child.tag != "text":
975            continue
976        if testMetadataAbstractElement(child, reporter, tag, optionalAttributes=["lang"], requireText=True, noteMissingOptionalAttributes=False):
977            haveError = True
978    return haveError
979
980def testMetadataAbstractTextElementLanguages(element, reporter, tag):
981    """
982    Tests:
983    - duplicate languages
984    """
985    haveError = False
986    languages = {}
987    for child in element:
988        if child.tag != "text":
989            continue
990        lang = child.attrib.get("lang", "undefined")
991        if lang not in languages:
992            languages[lang] = 0
993        languages[lang] += 1
994    for lang, count in sorted(languages.items()):
995        if count > 1:
996            haveError = True
997            reporter.logError(message="More than one instance of language \"%s\" in the \"%s\" element." % (lang, tag))
998    return haveError
999
1000
1001# ------------
1002# Private Data
1003# ------------
1004
1005def testPrivateDataOffsetAndLength(data, reporter):
1006    """
1007    Tests:
1008    - if offset is zero, length is 0. vice-versa.
1009    - offset is before the end of the header/directory.
1010    - offset is after the end of the file.
1011    - offset + length is greater than the available length.
1012    - length is longer than the available length.
1013    - offset begins immediately after last table.
1014    - offset begins on 4-byte boundary.
1015    """
1016    header = unpackHeader(data)
1017    privOffset = header["privOffset"]
1018    privLength = header["privLength"]
1019    # empty offset or length
1020    if privOffset == 0 or privLength == 0:
1021        if privOffset == 0 and privLength == 0:
1022            reporter.logPass(message="The length and offset are appropriately set for empty private data.")
1023        else:
1024            reporter.logError(message="The private data offset (%d) and private data length (%d) are not properly set. If one is 0, they both must be 0." % (privOffset, privLength))
1025        return
1026    # 4-byte boundary
1027    if privOffset % 4:
1028        reporter.logError(message="The private data does not begin on a four-byte boundary.")
1029        return
1030    # borders
1031    totalLength = header["length"]
1032    numTables = header["numTables"]
1033    directory = unpackDirectory(data)
1034    offsets = [headerSize + (directorySize * numTables)]
1035    for table in directory:
1036        tag = table["tag"]
1037        offset = table["offset"]
1038        length = table["compLength"]
1039        offsets.append(offset + length)
1040    if header["metaOffset"] != 0:
1041        offsets.append(header["metaOffset"] + header["metaLength"])
1042    minOffset = max(offsets)
1043    if minOffset % 4:
1044        minOffset += 4 - (minOffset % 4)
1045    maxLength = totalLength - minOffset
1046    offsetErrorMessage = "The metadata has an invalid offset (%d)." % privOffset
1047    lengthErrorMessage = "The metadata has an invalid length (%d)." % privLength
1048    if privOffset < minOffset:
1049        reporter.logError(message=offsetErrorMessage)
1050    elif privOffset > totalLength:
1051        reporter.logError(message=offsetErrorMessage)
1052    elif (privOffset + privLength) > totalLength:
1053        reporter.logError(message=lengthErrorMessage)
1054    elif privLength > maxLength:
1055        reporter.logError(message=lengthErrorMessage)
1056    elif privOffset != minOffset:
1057        reporter.logError(message=offsetErrorMessage)
1058    else:
1059        reporter.logPass(message="The private data has properly set offset and length.")
1060
1061# ---------
1062# Reporters
1063# ---------
1064
1065class TestResultGroup(list):
1066
1067    def __init__(self, title):
1068        super(TestResultGroup, self).__init__()
1069        self.title = title
1070
1071    def _haveType(self, tp):
1072        for data in self:
1073            if data["type"] == tp:
1074                return True
1075        return False
1076
1077    def haveNote(self):
1078        return self._haveType("NOTE")
1079
1080    def haveWarning(self):
1081        return self._haveType("WARNING")
1082
1083    def haveError(self):
1084        return self._haveType("ERROR")
1085
1086    def havePass(self):
1087        return self._haveType("PASS")
1088
1089    def haveTraceback(self):
1090        return self._haveType("TRACEBACK")
1091
1092
1093defaultCSS = """
1094        body {
1095            background-color: #e5e5e5;
1096            padding: 0px;
1097            margin: 0px;
1098            font-family: Helvetica, Verdana, Arial, sans-serif;
1099        }
1100
1101        h2.readError {
1102            background-color: red;
1103            color: white;
1104            margin: 20px 15px 20px 15px;
1105            padding: 10px;
1106            border-radius: 5px;
1107            -webkit-border-radius: 5px;
1108            -moz-border-radius: 5px;
1109            -webkit-box-shadow: #999 0 2px 5px;
1110            -moz-box-shadow: #999 0 2px 5px;
1111            font-size: 25px;
1112        }
1113
1114        /* info blocks */
1115
1116        .infoBlock {
1117            background-color: white;
1118            margin: 20px 15px 20px 15px;
1119            padding: 10px;
1120            border-radius: 5px;
1121            -webkit-border-radius: 5px;
1122            -moz-border-radius: 5px;
1123            -webkit-box-shadow: #999 0 2px 5px;
1124            -moz-box-shadow: #999 0 2px 5px;
1125        }
1126
1127        h3.infoBlockTitle {
1128            font-size: 20px;
1129            margin: 0px 0px 15px 0px;
1130            padding: 0px 0px 10px 0px;
1131            border-bottom: 1px solid #e5e5e5;
1132        }
1133
1134        h4.infoBlockTitle {
1135            font-size: 17px;
1136            margin: 0px 0px 15px 0px;
1137            padding: 0px 0px 10px 0px;
1138            border-bottom: 1px solid #e5e5e5;
1139        }
1140
1141        table.report {
1142            border-collapse: collapse;
1143            width: 100%;
1144            font-size: 14px;
1145        }
1146
1147        table.report tr {
1148            border-top: 1px solid white;
1149        }
1150
1151        table.report tr.testPass {
1152            background-color: #c8ffaf;
1153        }
1154
1155        table.report tr.testError {
1156            background-color: #ffc3af;
1157        }
1158
1159        table.report tr.testWarning {
1160            background-color: #ffe1af;
1161        }
1162
1163        table.report tr.testNote {
1164            background-color: #96e1ff;
1165        }
1166
1167        table.report tr.testTraceback {
1168            background-color: red;
1169            color: white;
1170        }
1171
1172        table.report td {
1173            padding: 7px 5px 7px 5px;
1174            vertical-align: top;
1175        }
1176
1177        table.report td.title {
1178            width: 80px;
1179            text-align: right;
1180            font-weight: bold;
1181            text-transform: uppercase;
1182        }
1183
1184        .infoBlock td p.info {
1185            font-size: 12px;
1186            font-style: italic;
1187            margin: 5px 0px 0px 0px;
1188        }
1189
1190        /* SFNT table */
1191
1192        table.sfntTableData {
1193            font-size: 12px;
1194            width: 100%;
1195            border-collapse: collapse;
1196            padding: 0px;
1197        }
1198
1199        table.sfntTableData th {
1200            padding: 5px 0px 5px 0px;
1201            text-align: left
1202        }
1203
1204        table.sfntTableData td {
1205            width: 20%;
1206            padding: 5px 0px 5px 0px;
1207            border: 1px solid #e5e5e5;
1208            border-left: none;
1209            border-right: none;
1210            font-family: Monaco, monospace;
1211        }
1212
1213        /* Metadata */
1214
1215        .metadataElement {
1216            background: rgba(0, 0, 0, 0.02);
1217            margin: 10px 0px 10px 0px;
1218            border: 2px solid #e5e5e5;
1219            border-right: none;
1220            padding: 10px 0px 10px 10px;
1221        }
1222
1223        h5.metadata {
1224            font-size: 14px;
1225            margin: 5px 0px 10px 0px;
1226            padding: 0px 0px 5px 0px;
1227            border-bottom: 1px solid #e5e5e5;
1228        }
1229
1230        h6.metadata {
1231            font-size: 12px;
1232            font-weight: normal;
1233            margin: 10px 0px 10px 0px;
1234            padding: 0px 0px 5px 0px;
1235            border-bottom: 1px solid #e5e5e5;
1236        }
1237
1238        table.metadata {
1239            font-size: 12px;
1240            width: 100%;
1241            border-collapse: collapse;
1242            padding: 0px;
1243        }
1244
1245        table.metadata td.key {
1246            width: 5em;
1247            padding: 5px 5px 5px 0px;
1248            border-right: 1px solid #e5e5e5;
1249            text-align: right;
1250            vertical-align: top;
1251        }
1252
1253        table.metadata td.value {
1254            padding: 5px 0px 5px 5px;
1255            border-left: 1px solid #e5e5e5;
1256            text-align: left;
1257            vertical-align: top;
1258        }
1259
1260        p.metadata {
1261            font-size: 12px;
1262            font-style: italic;
1263        }
1264
1265        /* Private Data */
1266
1267        p.privateData {
1268            font-size: 12px;
1269            margin: 0px;
1270            padding: 0px;
1271        }
1272
1273"""
1274
1275class HTMLReporter(object):
1276
1277    def __init__(self):
1278        self.fileInfo = []
1279        self.tableInfo = []
1280        self.metadata = ""
1281        self.privateData = ""
1282        self.testResults = []
1283        self.haveReadError = False
1284
1285    def logFileInfo(self, title, value):
1286        self.fileInfo.append((title, value))
1287
1288    def logTableInfo(self, tag=None, offset=None, compLength=None, origLength=None, origChecksum=None):
1289        self.tableInfo.append((tag, offset, compLength, origLength, origChecksum))
1290
1291    def logTestTitle(self, title):
1292        self.testResults.append(TestResultGroup(title))
1293
1294    def logNote(self, message, information=""):
1295        d = dict(type="NOTE", message=message, information=information)
1296        self.testResults[-1].append(d)
1297
1298    def logWarning(self, message, information=""):
1299        d = dict(type="WARNING", message=message, information=information)
1300        self.testResults[-1].append(d)
1301
1302    def logError(self, message, information=""):
1303        d = dict(type="ERROR", message=message, information=information)
1304        self.testResults[-1].append(d)
1305
1306    def logPass(self, message, information=""):
1307        d = dict(type="PASS", message=message, information=information)
1308        self.testResults[-1].append(d)
1309
1310    def logTraceback(self, text):
1311        d = dict(type="TRACEBACK", message=text, information="")
1312        self.testResults[-1].append(d)
1313
1314    def getReport(self):
1315        from xmlWriter import XMLWriter
1316        ioFile = ReporterFileProxy()
1317        writer = XMLWriter(ioFile, encoding="utf-8")
1318        # start the html
1319        writer.begintag("html")
1320        writer.newline()
1321        # start the head
1322        writer.begintag("head")
1323        writer.newline()
1324        # write the css
1325        writer.begintag("style", type="text/css")
1326        writer.write(defaultCSS)
1327        writer.endtag("style")
1328        writer.newline()
1329        # close the head
1330        writer.endtag("head")
1331        writer.newline()
1332        # start the body
1333        writer.begintag("body")
1334        writer.newline()
1335        self._writeFileInfo(writer)
1336        # write major error alert
1337        if self.haveReadError:
1338            self._writeMajorError(writer)
1339        # write table, metadata and private data information
1340        else:
1341            self._writeSFNTReport(writer)
1342            self._writeMetadata(writer)
1343            self._writePrivateData(writer)
1344        # write the test overview
1345        self._writeTestResultsOverview(writer)
1346        # write the test groups
1347        self._writeTestResults(writer)
1348        # close the body
1349        writer.endtag("body")
1350        writer.newline()
1351        # close the html
1352        writer.endtag("html")
1353        # done
1354        text = ioFile.getvalue()
1355        return text.replace("c_l_a_s_s", "class")
1356
1357    def _writeFileInfo(self, writer):
1358        # write the font info
1359        writer.begintag("div", c_l_a_s_s="infoBlock")
1360        writer.newline()
1361        ## title
1362        writer.begintag("h3", c_l_a_s_s="infoBlockTitle")
1363        writer.write("File Information")
1364        writer.endtag("h3")
1365        writer.newline()
1366        ## table
1367        writer.begintag("table", c_l_a_s_s="report")
1368        writer.newline()
1369        ## items
1370        for title, value in self.fileInfo:
1371            # row
1372            writer.begintag("tr")
1373            writer.newline()
1374            # title
1375            writer.begintag("td", c_l_a_s_s="title")
1376            writer.write(title)
1377            writer.endtag("td")
1378            # message
1379            writer.begintag("td")
1380            writer.newline()
1381            writer.write(value)
1382            writer.newline()
1383            writer.endtag("td")
1384            writer.newline()
1385            # close row
1386            writer.endtag("tr")
1387            writer.newline()
1388        writer.endtag("table")
1389        writer.newline()
1390        ## close the container
1391        writer.endtag("div")
1392        writer.newline()
1393
1394    def _writeMajorError(self, writer):
1395        writer.begintag("h2", c_l_a_s_s="readError")
1396        writer.write("The file contains major structural errors!")
1397        writer.endtag("h2")
1398        writer.newline()
1399
1400    def _writeSFNTReport(self, writer):
1401        # tables
1402        ## start the block
1403        writer.begintag("div", c_l_a_s_s="infoBlock")
1404        writer.newline()
1405        ## title
1406        writer.begintag("h3", c_l_a_s_s="infoBlockTitle")
1407        writer.write("sfnt Tables")
1408        writer.endtag("h3")
1409        writer.newline()
1410        ## tables
1411        writer.begintag("table", c_l_a_s_s="sfntTableData")
1412        writer.newline()
1413        writer.begintag("tr")
1414        columns = "tag offset compLength origLength origChecksum".split()
1415        for c in columns:
1416            writer.begintag("th")
1417            writer.write(c)
1418            writer.endtag("th")
1419        writer.endtag("tr")
1420        writer.newline()
1421        for tag, offset, compLength, origLength, origChecksum in self.tableInfo:
1422            writer.begintag("tr")
1423            for v in (tag, offset, compLength, origLength, origChecksum):
1424                writer.begintag("td")
1425                writer.write(v)
1426                writer.endtag("td")
1427            writer.endtag("tr")
1428            writer.newline()
1429        writer.endtag("table")
1430        writer.newline()
1431        ## close the block
1432        writer.endtag("div")
1433        writer.newline()
1434
1435    def _writeMetadata(self, writer):
1436        # metadata
1437        ## start the block
1438        writer.begintag("div", c_l_a_s_s="infoBlock")
1439        writer.newline()
1440        ## title
1441        writer.begintag("h3", c_l_a_s_s="infoBlockTitle")
1442        writer.write("Metadata")
1443        writer.endtag("h3")
1444        writer.newline()
1445        ### content
1446        for element in self.metadata:
1447            self._writeMetadataElement(writer, element)
1448        ## close the block
1449        writer.endtag("div")
1450        writer.newline()
1451
1452    def _writeMetadataElement(self, writer, element):
1453        writer.begintag("div", c_l_a_s_s="metadataElement")
1454        writer.newline()
1455        # tag
1456        writer.begintag("h5", c_l_a_s_s="metadata")
1457        writer.write(element.tag)
1458        writer.endtag("h5")
1459        writer.newline()
1460        # attributes
1461        if len(element.attrib):
1462            writer.begintag("h6", c_l_a_s_s="metadata")
1463            writer.write("Attributes:")
1464            writer.endtag("h6")
1465            writer.newline()
1466            # key, value pairs
1467            writer.begintag("table", c_l_a_s_s="metadata")
1468            writer.newline()
1469            for key, value in sorted(element.attrib.items()):
1470                writer.begintag("tr")
1471                writer.begintag("td", c_l_a_s_s="key")
1472                writer.write(key)
1473                writer.endtag("td")
1474                writer.begintag("td", c_l_a_s_s="value")
1475                writer.write(value)
1476                writer.endtag("td")
1477                writer.endtag("tr")
1478                writer.newline()
1479            writer.endtag("table")
1480            writer.newline()
1481        # text
1482        if element.text is not None and element.text.strip():
1483            writer.begintag("h6", c_l_a_s_s="metadata")
1484            writer.write("Text:")
1485            writer.endtag("h6")
1486            writer.newline()
1487            writer.begintag("p", c_l_a_s_s="metadata")
1488            writer.write(element.text)
1489            writer.endtag("p")
1490            writer.newline()
1491        # child elements
1492        if len(element):
1493            writer.begintag("h6", c_l_a_s_s="metadata")
1494            writer.write("Child Elements:")
1495            writer.endtag("h6")
1496            writer.newline()
1497            for child in element:
1498                self._writeMetadataElement(writer, child)
1499        # close
1500        writer.endtag("div")
1501        writer.newline()
1502
1503    def _writePrivateData(self, writer):
1504        # metadata
1505        ## start the block
1506        writer.begintag("div", c_l_a_s_s="infoBlock")
1507        writer.newline()
1508        ## title
1509        writer.begintag("h3", c_l_a_s_s="infoBlockTitle")
1510        writer.write("Private Data")
1511        writer.endtag("h3")
1512        writer.newline()
1513        # content
1514        if self.privateData:
1515            writer.begintag("p", c_l_a_s_s="privateData")
1516            writer.write(self.privateData)
1517            writer.endtag("p")
1518            writer.newline()
1519        ## close the block
1520        writer.endtag("div")
1521        writer.newline()
1522
1523    def _writeTestResultsOverview(self, writer):
1524        ## tabulate
1525        notes = 0
1526        passes = 0
1527        errors = 0
1528        warnings = 0
1529        for group in self.testResults:
1530            for data in group:
1531                tp = data["type"]
1532                if tp == "NOTE":
1533                    notes += 1
1534                elif tp == "PASS":
1535                    passes += 1
1536                elif tp == "ERROR":
1537                    errors += 1
1538                else:
1539                    warnings += 1
1540        total = sum((notes, passes, errors, warnings))
1541        ## container
1542        writer.begintag("div", c_l_a_s_s="infoBlock")
1543        writer.newline()
1544        ## header
1545        writer.begintag("h3", c_l_a_s_s="infoBlockTitle")
1546        writer.write("Results for %d Tests" % total)
1547        writer.endtag("h3")
1548        writer.newline()
1549        ## results
1550        results = [
1551            ("PASS", passes),
1552            ("WARNING", warnings),
1553            ("ERROR", errors),
1554            ("NOTE", notes),
1555        ]
1556        writer.begintag("table", c_l_a_s_s="report")
1557        writer.newline()
1558        for tp, value in results:
1559            writer.begintag("tr", c_l_a_s_s="test%s" % tp.title())
1560            writer.newline()
1561            writer.begintag("td", c_l_a_s_s="title")
1562            writer.write(tp)
1563            writer.endtag("td")
1564            writer.newline()
1565            writer.begintag("td")
1566            writer.newline()
1567            writer.write(str(value))
1568            writer.newline()
1569            writer.endtag("td")
1570            writer.newline()
1571            writer.endtag("tr")
1572            writer.newline()
1573        writer.endtag("table")
1574        writer.newline()
1575        ## close the container
1576        writer.endtag("div")
1577
1578    def _writeTestResults(self, writer):
1579        for infoBlock in self.testResults:
1580            # container
1581            writer.begintag("div", c_l_a_s_s="infoBlock")
1582            writer.newline()
1583            # header
1584            writer.begintag("h4", c_l_a_s_s="infoBlockTitle")
1585            writer.write(infoBlock.title)
1586            writer.endtag("h4")
1587            writer.newline()
1588            # individual reports
1589            writer.begintag("table", c_l_a_s_s="report")
1590            writer.newline()
1591            for data in infoBlock:
1592                tp = data["type"]
1593                message = data["message"]
1594                information = data["information"]
1595                # row
1596                writer.begintag("tr", c_l_a_s_s="test%s" % tp.title())
1597                writer.newline()
1598                # title
1599                writer.begintag("td", c_l_a_s_s="title")
1600                writer.write(tp)
1601                writer.endtag("td")
1602                # message
1603                writer.begintag("td")
1604                writer.newline()
1605                writer.write(message)
1606                writer.newline()
1607                ## info
1608                if information:
1609                    writer.begintag("p", c_l_a_s_s="info")
1610                    writer.write(information)
1611                    writer.endtag("p")
1612                    writer.newline()
1613                writer.endtag("td")
1614                writer.newline()
1615                # close row
1616                writer.endtag("tr")
1617                writer.newline()
1618            writer.endtag("table")
1619            writer.newline()
1620            # close container
1621            writer.endtag("div")
1622            writer.newline()
1623
1624
1625class ReporterFileProxy(object):
1626
1627    def __init__(self):
1628        self._data = []
1629
1630    def write(self, data):
1631        self._data.append(data)
1632
1633    def getvalue(self):
1634        text = u"".join(self._data)
1635        return text.encode("utf-8")
1636
1637
1638# ----------------
1639# Helper Functions
1640# ----------------
1641
1642def unpackHeader(data):
1643    return sstruct.unpack2(headerFormat, data)[0]
1644
1645def unpackDirectory(data):
1646    header = unpackHeader(data)
1647    numTables = header["numTables"]
1648    data = data[headerSize:]
1649    directory = []
1650    for index in range(numTables):
1651        table, data = sstruct.unpack2(directoryFormat, data)
1652        directory.append(table)
1653    return directory
1654
1655def unpackTableData(data):
1656    directory = unpackDirectory(data)
1657    tables = {}
1658    for entry in directory:
1659        tag = entry["tag"]
1660        offset = entry["offset"]
1661        origLength = entry["origLength"]
1662        compLength = entry["compLength"]
1663        tableData = data[offset:offset+compLength]
1664        if compLength < origLength:
1665            tableData = zlib.decompress(tableData)
1666        tables[tag] = tableData
1667    return tables
1668
1669def unpackMetadata(data, decompress=True, parse=True):
1670    header = unpackHeader(data)
1671    data = data[header["metaOffset"]:header["metaOffset"]+header["metaLength"]]
1672    if decompress:
1673        data = zlib.decompress(data)
1674    if parse:
1675        data = ElementTree.fromstring(data)
1676    return data
1677
1678def unpackPrivateData(data):
1679    header = unpackHeader(data)
1680    data = data[header["privOffset"]:header["privOffset"]+header["privLength"]]
1681    return data
1682
1683# adapted from fontTools.ttLib.sfnt
1684
1685def calcChecksum(tag, data):
1686    if tag == "head":
1687        data = data[:8] + '\0\0\0\0' + data[12:]
1688    data = padData(data)
1689    a = numpy.fromstring(struct.pack(">l", 0) + data, numpy.int32)
1690    if sys.byteorder != "big":
1691        a = a.byteswap()
1692    return numpy.add.reduce(a)
1693
1694def calcHeadCheckSum(data):
1695    header = unpackHeader(data)
1696    directory = unpackDirectory(data)
1697    tables = unpackTableData(data)
1698    numTables = header["numTables"]
1699    # compile the SFNT header
1700    searchRange, entrySelector, rangeShift = getSearchRange(numTables)
1701    sfntDirectoryData = dict(
1702        sfntVersion=header["flavor"],
1703        numTables=numTables,
1704        searchRange=searchRange,
1705        entrySelector=entrySelector,
1706        rangeShift=rangeShift
1707    )
1708    sfntDirectory = sstruct.pack(sfntDirectoryFormat, sfntDirectoryData)
1709    # create the SFNT directory
1710    sfntEntries = {}
1711    offset = sfntDirectorySize + (sfntDirectoryEntrySize * numTables)
1712    for entry in directory:
1713        tag = entry["tag"]
1714        sfntEntry = SFNTDirectoryEntry()
1715        sfntEntry.tag = tag
1716        sfntEntry.offset = offset
1717        sfntEntry.length = entry["origLength"]
1718        # set the checkSumAdjustment to 0
1719        if tag == "head":
1720            checksum = calcChecksum(tag, tables[tag])
1721        # use the original checksum
1722        else:
1723            checksum = entry["origChecksum"]
1724        sfntEntry.checkSum = checksum
1725        sfntEntries[tag] = sfntEntry
1726    # add the compiled entries to the directory
1727    for tag, sfntEntry in sorted(sfntEntries.items()):
1728        sfntDirectory += sfntEntry.toString()
1729    # compile the table checksums
1730    tags = tables.keys()
1731    checksums = numpy.zeros(len(tags) + 1, numpy.int32)
1732    for index, entry in enumerate(directory):
1733        checksums[index] = sfntEntries[entry["tag"]].checkSum
1734    # add the directory checksum
1735    checksums[-1] = calcChecksum(None, sfntDirectory)
1736    # sum
1737    checksum = numpy.add.reduce(checksums)
1738    # do the required translation
1739    checkSumAdjustment = numpy.array(0xb1b0afbaL - 0x100000000L, numpy.int32) - checksum
1740    # done
1741    return checkSumAdjustment
1742
1743def padData(data):
1744    remainder = len(data) % 4
1745    if remainder:
1746        data += "\0" * (4 - remainder)
1747    return data
1748
1749# -------------------
1750# Execution Functions
1751# -------------------
1752
1753tests = [
1754    ("Header - Size",                       "h-size",               testHeaderSize),
1755    ("Header - Structure",                  "h-structure",          testHeaderStructure),
1756    ("Header - Signature",                  "h-signature",          testHeaderSignature),
1757    ("Header - Flavor",                     "h-flavor",             testHeaderFlavor),
1758    ("Header - Length",                     "h-length",             testHeaderLength),
1759    ("Header - Reserved",                   "h-reserved",           testHeaderReserved),
1760    ("Header - Total sfnt Size",            "h-sfntsize",           testHeaderTotalSFNTSize),
1761    ("Header - Version",                    "h-version",            testHeaderMajorVersionAndMinorVersion),
1762    ("Header - Number of Tables",           "h-numtables",          testHeaderNumTables),
1763    ("Directory - Table Order",             "d-order",              testDirectoryTableOrder),
1764    ("Directory - Table Borders",           "d-borders",            testDirectoryBorders),
1765    ("Directory - Compressed Length",       "d-complength",         testDirectoryCompressedLength),
1766    ("Directory - Table Checksums",         "d-checksum",           testDirectoryChecksums),
1767    ("Tables - Start Position",             "t-start",              testTableDataStart),
1768    ("Tables - Padding",                    "t-padding",            testTablePadding),
1769    ("Tables - Decompression",              "t-decompression",      testTableDecompression),
1770    ("Tables - Original Length",            "t-origlength",         testDirectoryDecompressedLength),
1771    ("Tables - checkSumAdjustment",         "t-headchecksum",       testHeadCheckSumAdjustment),
1772    ("Tables - DSIG",                       "t-dsig",               testDSIG),
1773    ("Metadata - Offset and Length",        "m-offsetlength",       testMetadataOffsetAndLength),
1774    ("Metadata - Decompression",            "m-decompression",      testMetadataDecompression),
1775    ("Metadata - Original Length",          "m-metaOriglength",     testMetadataDecompressedLength),
1776    ("Metadata - Parse",                    "m-parse",              testMetadataParse),
1777    ("Metadata - Structure",                "m-structure",          testMetadataStructure),
1778    ("Private Data - Offset and Length",    "m-structure",          testPrivateDataOffsetAndLength),
1779]
1780
1781def testFont(path, options):
1782    reporter = HTMLReporter()
1783    # log fileinfo
1784    reporter.logFileInfo("file", path)
1785    size = os.path.getsize(path)
1786    size = size * .001
1787    reporter.logFileInfo("file size", str(size) + "k")
1788    # run tests and log results
1789    skip = options.excludeTests
1790    f = open(path, "rb")
1791    data = f.read()
1792    f.close()
1793    shouldStop = False
1794    for title, tag, func in tests:
1795        if tag in skip:
1796            continue
1797        reporter.logTestTitle(title)
1798        shouldStop = func(data, reporter)
1799        if shouldStop:
1800            break
1801    reporter.haveReadError = shouldStop
1802    # log the table, metadata and private data info
1803    if not shouldStop:
1804        # tables
1805        directory = unpackDirectory(data)
1806        sorter = [(entry["offset"], entry) for entry in directory]
1807        for offset, entry in sorted(sorter):
1808            reporter.logTableInfo(
1809                tag=entry["tag"],
1810                offset=str(entry["offset"]),
1811                compLength=str(entry["compLength"]),
1812                origLength=str(entry["origLength"]),
1813                origChecksum=str(entry["origChecksum"])
1814            )
1815        # metadata
1816        reporter.metadata = unpackMetadata(data)
1817        # private data
1818        reporter.privateData = unpackPrivateData(data)
1819    # make the output file name
1820    if options.outputFileName is not None:
1821        fileName = options.outputFileName
1822    else:
1823        fileName = os.path.splitext(os.path.basename(path))[0]
1824        fileName += "_report"
1825        fileName += ".html"
1826    # make the output directory
1827    if options.outputDirectory is not None:
1828        directory = options.outputDirectory
1829    else:
1830        directory = os.path.dirname(path)
1831    # write the file
1832    reportPath = os.path.join(directory, fileName)
1833    report = reporter.getReport()
1834    f = open(reportPath, "wb")
1835    f.write(report)
1836    f.close()
1837
1838usage = "%prog [options] fontpath1 fontpath2"
1839
1840description = """This tool examines the structure of one
1841or more WebOTF files and issues a detailed report about
1842the validity of the file structure. It does not validate
1843the wrapped font data. The tests: header, directory.
1844"""
1845
1846def main():
1847    import sys
1848    import optparse
1849    parser = optparse.OptionParser(usage=usage, description=description, version="%prog 0.1b")
1850    parser.add_option("-x", action="append", dest="excludeTests", help="Exclude tests. Supply the identifier listed above.")
1851    parser.add_option("-d", dest="outputDirectory", help="Output directory. The default is to output the report into the same directory as the font file.")
1852    parser.add_option("-o", dest="outputFileName", help="Output file name. The default is \"fontfilename_report.html\".")
1853    parser.set_defaults(excludeTests=[])
1854    (options, args) = parser.parse_args()
1855    outputDirectory = options.outputDirectory
1856    if outputDirectory is not None and not os.path.exists(outputDirectory):
1857        print "Directory does not exist:", outputDirectory
1858        sys.exit()
1859    for fontPath in args:
1860        if not os.path.exists(fontPath):
1861            print "File does not exist:", fontPath
1862            sys.exit()
1863        else:
1864            print "Testing: %s..." % fontPath
1865            testFont(fontPath, options)
1866
1867def runDocTests():
1868    import doctest
1869    doctest.testmod(verbose=False)
1870
1871if __name__ == "__main__":
1872    main()
Note: See TracBrowser for help on using the repository browser.