source: applications/WOFFValidator/woffValidator.py @ 617

Revision 617, 50.5 KB checked in by tal, 4 years ago (diff)
More tests and required changes.
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 unique id
628    foundUniqueid = False
629    for child in tree:
630        if child.tag == "uniqueid":
631            foundUniqueid = True
632    if not foundUniqueid:
633        reporter.logWarning(message="No \"uniqueid\" child is in the \"metadata\" element.")
634    # shuttle elements
635    for element in tree:
636        if element.tag == "uniqueid":
637            testMetadataUniqueid(element, reporter)
638        elif element.tag == "vendor":
639            testMetadataVendor(element, reporter)
640        elif element.tag == "credits":
641            testMetadataCredits(element, reporter)
642        elif element.tag == "description":
643            testMetadataDescription(element, reporter)
644        elif element.tag == "license":
645            pass
646        elif element.tag == "copyright":
647            pass
648        elif element.tag == "trademark":
649            pass
650        elif element.tag == "licensee":
651            pass
652        else:
653            reporter.logWarning(
654                message="Unknown \"%s\" element." % element.tag,
655                information="This element will be unknown to user agents.")
656
657def testMetadataUniqueid(element, reporter):
658    """
659    Tests:
660    - id is present and contains text
661    - unknown attributes
662    - child-elements
663    """
664    required = "id".split(" ")
665    haveError = testMetadataAbstractElement(element, reporter, tag="uniqueid", requiredAttributes=required)
666    if not haveError:
667        reporter.logPass(message="The \"uniqueid\" element is properly formatted.")
668
669def testMetadataVendor(element, reporter):
670    """
671    Tests:
672    - name is present and contains text
673    - url is not present (note)
674    - url is not empty
675    - unknown attributes
676    - child-elements
677    """
678    required = "name".split(" ")
679    optional = "url".split(" ")
680    haveError = testMetadataAbstractElement(element, reporter, tag="vendor", requiredAttributes=required, optionalAttributes=optional)
681    if not haveError:
682        reporter.logPass(message="The \"vendor\" element is properly formatted.")
683
684def testMetadataCredits(element, reporter):
685    """
686    Tests:
687    - no attributes
688    - text
689    - has at least one child element
690    - unknown child elements
691    """
692    haveError = True
693    if testMetadataAbstractElement(element, reporter, tag="vendor", allowChildElements=True):
694        haveError = True
695    # run through child elements
696    foundChild = False
697    for child in element:
698        if child.tag == "credit":
699            foundChild = True
700        # unknown child element
701        else:
702            reporter.logWarning(
703                message="Unknown \"%s\" child element in \"credits\" element." % child.tag,
704                information="This element will be unknown to user agents.")
705    # at least one credit child element
706    if not foundChild:
707        reporter.logError(message="No \"credit\" child element found within the \"credits\" element.")
708        haveError = True
709    if not haveError:
710        reporter.logPass(message="The \"credits\" element is properly formatted.")
711    # test credit child elements
712    for child in element:
713        if child.tag == "credit":
714            testMetadataCredit(child, reporter)
715
716def testMetadataCredit(element, reporter):
717    """
718    Tests:
719    - name is present and contains text
720    - url is not present (note)
721    - url is not empty
722    - role is not present (note)
723    - role is not empty
724    - unknown attributes
725    - child-elements
726    """
727    required = "name".split(" ")
728    optional = "url role".split(" ")
729    haveError = testMetadataAbstractElement(element, reporter, tag="credit", requiredAttributes=required, optionalAttributes=optional)
730    if not haveError:
731        reporter.logPass(message="The \"credit\" element is properly formatted.")
732
733def testMetadataDescription(element, reporter):
734    haveError = False
735    haveError = testMetadataAbstractElement(element, reporter, tag="vendor", allowChildElements=True)
736    if not haveError:
737        reporter.logPass(message="The \"description\" element is properly formatted.")
738
739# support
740
741def testMetadataAbstractElement(element, reporter, tag, requiredAttributes=[], optionalAttributes=[], allowText=False, allowChildElements=False):
742    haveError = False
743    # missing required attribute
744    if testMetadataAbstractElementRequiredAttributes(element, reporter, tag, requiredAttributes):
745        haveError = True
746    # missing optional attributes
747    testMetadataAbstractElementOptionalAttributes(element, reporter, tag, optionalAttributes)
748    # unknown attributes
749    if testMetadataAbstractElementUnknownAttributes(element, reporter, tag, requiredAttributes, optionalAttributes):
750        haveError = True
751    # empty values
752    if testMetadataAbstractElementEmptyValue(element, reporter, tag, requiredAttributes, optionalAttributes):
753        haveError = True
754    # text
755    if testMetadataAbstractElementText(element, reporter, tag, allowText):
756        haveError = True
757    # child elements
758    if testMetadataAbstractElementChildElements(element, reporter, tag, allowChildElements):
759        haveError = True
760    return haveError
761
762def testMetadataAbstractElementRequiredAttributes(element, reporter, tag, requiredAttributes):
763    haveError = False
764    for attr in sorted(requiredAttributes):
765        if attr not in element.attrib:
766            reporter.logError(message="Required attribute \"%s\" is not defined in the \"%s\" element." % (attr, tag))
767            haveError = True
768    return haveError
769
770def testMetadataAbstractElementOptionalAttributes(element, reporter, tag, optionalAttributes):
771    for attr in sorted(optionalAttributes):
772        if attr not in element.attrib:
773            reporter.logNote(message="Optional attribute \"%s\" is not defined in the \"%s\" element." % (attr, tag))
774
775def testMetadataAbstractElementUnknownAttributes(element, reporter, tag, requiredAttributes, optionalAttributes):
776    haveError = False
777    for attr in sorted(element.attrib.keys()):
778        if attr not in requiredAttributes and attr not in optionalAttributes:
779            reporter.logWarning(
780                message="Unknown \"%s\" attribute of \"%s\" element." % (attr, tag),
781                information="This attribute will be unknown to user agents.")
782            haveError = True
783    return haveError
784
785def testMetadataAbstractElementEmptyValue(element, reporter, tag, requiredAttributes, optionalAttributes):
786    haveError = False
787    for attr, value in sorted(element.attrib.items()):
788        # skip unknown attributes
789        if attr not in requiredAttributes and attr not in optionalAttributes:
790            continue
791        # empty value
792        elif not value.strip():
793            reporter.logError(message="The value for the \"%s\" attribute in the \"%s\" element is an empty string." % (attr, tag))
794            haveError = True
795    return haveError
796
797def testMetadataAbstractElementText(element, reporter, tag, allowText=False):
798    haveError = False
799    if not allowText:
800        if element.text is not None and element.text.strip():
801            reporter.logError("Text defined in \"%s\" element." % tag)
802            haveError = True
803    return haveError
804
805def testMetadataAbstractElementChildElements(element, reporter, tag, allowChildElements=False):
806    haveError = False
807    if not allowChildElements:
808        if len(element):
809            reporter.logError("Child elements defined in \"%s\" element." % tag)
810            haveError = True
811    return haveError
812
813
814def _testTextBasedElement(element):
815    """
816    Tests:
817    - no text child element
818    - unknown child element
819    - duplicate language
820    """
821    pass
822
823# ---------
824# Reporters
825# ---------
826
827class TestResultGroup(list):
828
829    def __init__(self, title):
830        super(TestResultGroup, self).__init__()
831        self.title = title
832
833    def _haveType(self, tp):
834        for data in self:
835            if data["type"] == tp:
836                return True
837        return False
838
839    def haveNote(self):
840        return self._haveType("NOTE")
841
842    def haveWarning(self):
843        return self._haveType("WARNING")
844
845    def haveError(self):
846        return self._haveType("ERROR")
847
848    def havePass(self):
849        return self._haveType("PASS")
850
851    def haveTraceback(self):
852        return self._haveType("TRACEBACK")
853
854
855defaultCSS = """
856        body {
857            background-color: #e5e5e5;
858            padding: 0px;
859            margin: 0px;
860            font-family: Helvetica, Verdana, Arial, sans-serif;
861        }
862
863        h2.readError {
864            background-color: red;
865            color: white;
866            margin: 20px 15px 20px 15px;
867            padding: 10px;
868            border-radius: 5px;
869            -webkit-border-radius: 5px;
870            -moz-border-radius: 5px;
871            -webkit-box-shadow: #999 0 2px 5px;
872            -moz-box-shadow: #999 0 2px 5px;
873            font-size: 25px;
874        }
875
876        /* info blocks */
877
878        .infoBlock {
879            background-color: white;
880            margin: 20px 15px 20px 15px;
881            padding: 10px;
882            border-radius: 5px;
883            -webkit-border-radius: 5px;
884            -moz-border-radius: 5px;
885            -webkit-box-shadow: #999 0 2px 5px;
886            -moz-box-shadow: #999 0 2px 5px;
887        }
888
889        h3.infoBlockTitle {
890            font-size: 20px;
891            margin: 0px 0px 15px 0px;
892            padding: 0px 0px 10px 0px;
893            border-bottom: 1px solid #e5e5e5;
894        }
895
896        h4.infoBlockTitle {
897            font-size: 17px;
898            margin: 0px 0px 15px 0px;
899            padding: 0px 0px 10px 0px;
900            border-bottom: 1px solid #e5e5e5;
901        }
902
903        table.report {
904            border-collapse: collapse;
905            width: 100%;
906            font-size: 14px;
907        }
908
909        table.report tr {
910            border-top: 1px solid white;
911        }
912
913        table.report tr.testPass {
914            background-color: #c8ffaf;
915        }
916
917        table.report tr.testError {
918            background-color: #ffc3af;
919        }
920
921        table.report tr.testWarning {
922            background-color: #ffe1af;
923        }
924
925        table.report tr.testNote {
926            background-color: #96e1ff;
927        }
928
929        table.report tr.testTraceback {
930            background-color: red;
931            color: white;
932        }
933
934        table.report td {
935            padding: 7px 5px 7px 5px;
936            vertical-align: top;
937        }
938
939        table.report td.title {
940            width: 80px;
941            text-align: right;
942            font-weight: bold;
943            text-transform: uppercase;
944        }
945
946        .infoBlock td p.info {
947            font-size: 12px;
948            font-style: italic;
949            margin: 5px 0px 0px 0px;
950        }
951
952        /* SFNT table */
953
954        table.sfntTableData {
955            font-size: 12px;
956            width: 100%;
957            border-collapse: collapse;
958            padding: 0px;
959        }
960
961        table.sfntTableData th {
962            padding: 5px 0px 5px 0px;
963            text-align: left
964        }
965
966        table.sfntTableData td {
967            width: 20%;
968            padding: 5px 0px 5px 0px;
969            border: 1px solid #e5e5e5;
970            border-left: none;
971            border-right: none;
972            font-family: Monaco, monospace;
973        }
974"""
975
976class HTMLReporter(object):
977
978    def __init__(self):
979        self.fileInfo = []
980        self.tableInfo = []
981        self.testResults = []
982        self.haveReadError = False
983
984    def logFileInfo(self, title, value):
985        self.fileInfo.append((title, value))
986
987    def logTableInfo(self, tag=None, offset=None, compLength=None, origLength=None, origChecksum=None):
988        self.tableInfo.append((tag, offset, compLength, origLength, origChecksum))
989
990    def logTestTitle(self, title):
991        self.testResults.append(TestResultGroup(title))
992
993    def logNote(self, message, information=""):
994        d = dict(type="NOTE", message=message, information=information)
995        self.testResults[-1].append(d)
996
997    def logWarning(self, message, information=""):
998        d = dict(type="WARNING", message=message, information=information)
999        self.testResults[-1].append(d)
1000
1001    def logError(self, message, information=""):
1002        d = dict(type="ERROR", message=message, information=information)
1003        self.testResults[-1].append(d)
1004
1005    def logPass(self, message, information=""):
1006        d = dict(type="PASS", message=message, information=information)
1007        self.testResults[-1].append(d)
1008
1009    def logTraceback(self, text):
1010        d = dict(type="TRACEBACK", message=text, information="")
1011        self.testResults[-1].append(d)
1012
1013    def getReport(self):
1014        from cStringIO import StringIO
1015        from xmlWriter import XMLWriter
1016        ioFile = StringIO()
1017        writer = XMLWriter(ioFile, encoding="utf-8")
1018        # start the html
1019        writer.begintag("html")
1020        writer.newline()
1021        # start the head
1022        writer.begintag("head")
1023        writer.newline()
1024        # write the css
1025        writer.begintag("style", type="text/css")
1026        writer.write(defaultCSS)
1027        writer.endtag("style")
1028        writer.newline()
1029        # close the head
1030        writer.endtag("head")
1031        writer.newline()
1032        # start the body
1033        writer.begintag("body")
1034        writer.newline()
1035        # write the font info
1036        writer.begintag("div", c_l_a_s_s="infoBlock")
1037        writer.newline()
1038        ## title
1039        writer.begintag("h3", c_l_a_s_s="infoBlockTitle")
1040        writer.write("File Information")
1041        writer.endtag("h3")
1042        writer.newline()
1043        ## table
1044        writer.begintag("table", c_l_a_s_s="report")
1045        writer.newline()
1046        ## items
1047        for title, value in self.fileInfo:
1048            # row
1049            writer.begintag("tr")
1050            writer.newline()
1051            # title
1052            writer.begintag("td", c_l_a_s_s="title")
1053            writer.write(title)
1054            writer.endtag("td")
1055            # message
1056            writer.begintag("td")
1057            writer.newline()
1058            writer.write(value)
1059            writer.newline()
1060            writer.endtag("td")
1061            writer.newline()
1062            # close row
1063            writer.endtag("tr")
1064            writer.newline()
1065        writer.endtag("table")
1066        writer.newline()
1067        ## close the container
1068        writer.endtag("div")
1069        writer.newline()
1070        # write major error alert
1071        if self.haveReadError:
1072            writer.begintag("h2", c_l_a_s_s="readError")
1073            writer.write("The file contains major structural errors!")
1074            writer.endtag("h2")
1075            writer.newline()
1076        # write table, metadata and private data information
1077        else:
1078            # tables
1079            ## start the block
1080            writer.begintag("div", c_l_a_s_s="infoBlock")
1081            writer.newline()
1082            ## title
1083            writer.begintag("h3", c_l_a_s_s="infoBlockTitle")
1084            writer.write("SFNT Tables")
1085            writer.endtag("h3")
1086            writer.newline()
1087            ## tables
1088            writer.begintag("table", c_l_a_s_s="sfntTableData")
1089            writer.newline()
1090            writer.begintag("tr")
1091            columns = "tag offset compLength origLength origChecksum".split()
1092            for c in columns:
1093                writer.begintag("th")
1094                writer.write(c)
1095                writer.endtag("th")
1096            writer.endtag("tr")
1097            writer.newline()
1098            for tag, offset, compLength, origLength, origChecksum in self.tableInfo:
1099                writer.begintag("tr")
1100                for v in (tag, offset, compLength, origLength, origChecksum):
1101                    writer.begintag("td")
1102                    writer.write(v)
1103                    writer.endtag("td")
1104                writer.endtag("tr")
1105                writer.newline()
1106            writer.endtag("table")
1107            writer.newline()
1108            ## close the block
1109            writer.endtag("div")
1110            writer.newline()
1111        # write the test overview
1112        ## tabulate
1113        notes = 0
1114        passes = 0
1115        errors = 0
1116        warnings = 0
1117        for group in self.testResults:
1118            for data in group:
1119                tp = data["type"]
1120                if tp == "NOTE":
1121                    notes += 1
1122                elif tp == "PASS":
1123                    passes += 1
1124                elif tp == "ERROR":
1125                    errors += 1
1126                else:
1127                    warnings += 1
1128        total = sum((notes, passes, errors, warnings))
1129        ## container
1130        writer.begintag("div", c_l_a_s_s="infoBlock")
1131        writer.newline()
1132        ## header
1133        writer.begintag("h3", c_l_a_s_s="infoBlockTitle")
1134        writer.write("Performed %d Tests" % total)
1135        writer.endtag("h3")
1136        writer.newline()
1137        ## results
1138        results = [
1139            ("PASS", passes),
1140            ("WARNING", warnings),
1141            ("ERROR", errors),
1142            ("NOTE", notes),
1143        ]
1144        writer.begintag("table", c_l_a_s_s="report")
1145        writer.newline()
1146        for tp, value in results:
1147            writer.begintag("tr", c_l_a_s_s="test%s" % tp.title())
1148            writer.newline()
1149            writer.begintag("td", c_l_a_s_s="title")
1150            writer.write(tp)
1151            writer.endtag("td")
1152            writer.newline()
1153            writer.begintag("td")
1154            writer.newline()
1155            writer.write(str(value))
1156            writer.newline()
1157            writer.endtag("td")
1158            writer.newline()
1159            writer.endtag("tr")
1160            writer.newline()
1161        writer.endtag("table")
1162        writer.newline()
1163        ## close the container
1164        writer.endtag("div")
1165        # write the test groups
1166        for infoBlock in self.testResults:
1167            # container
1168            writer.begintag("div", c_l_a_s_s="infoBlock")
1169            writer.newline()
1170            # header
1171            writer.begintag("h4", c_l_a_s_s="infoBlockTitle")
1172            writer.write(infoBlock.title)
1173            writer.endtag("h4")
1174            writer.newline()
1175            # individual reports
1176            writer.begintag("table", c_l_a_s_s="report")
1177            writer.newline()
1178            for data in infoBlock:
1179                tp = data["type"]
1180                message = data["message"]
1181                information = data["information"]
1182                # row
1183                writer.begintag("tr", c_l_a_s_s="test%s" % tp.title())
1184                writer.newline()
1185                # title
1186                writer.begintag("td", c_l_a_s_s="title")
1187                writer.write(tp)
1188                writer.endtag("td")
1189                # message
1190                writer.begintag("td")
1191                writer.newline()
1192                writer.write(message)
1193                writer.newline()
1194                ## info
1195                if information:
1196                    writer.begintag("p", c_l_a_s_s="info")
1197                    writer.write(information)
1198                    writer.endtag("p")
1199                    writer.newline()
1200                writer.endtag("td")
1201                writer.newline()
1202                # close row
1203                writer.endtag("tr")
1204                writer.newline()
1205            writer.endtag("table")
1206            writer.newline()
1207            # close container
1208            writer.endtag("div")
1209            writer.newline()
1210        # close the body
1211        writer.endtag("body")
1212        writer.newline()
1213        # close the html
1214        writer.endtag("html")
1215        # done
1216        text = ioFile.getvalue()
1217        return text.replace("c_l_a_s_s", "class")
1218
1219# ----------------
1220# Helper Functions
1221# ----------------
1222
1223def unpackHeader(data):
1224    return sstruct.unpack2(headerFormat, data)[0]
1225
1226def unpackDirectory(data):
1227    header = unpackHeader(data)
1228    numTables = header["numTables"]
1229    data = data[headerSize:]
1230    directory = []
1231    for index in range(numTables):
1232        table, data = sstruct.unpack2(directoryFormat, data)
1233        directory.append(table)
1234    return directory
1235
1236def unpackTableData(data):
1237    directory = unpackDirectory(data)
1238    tables = {}
1239    for entry in directory:
1240        tag = entry["tag"]
1241        offset = entry["offset"]
1242        origLength = entry["origLength"]
1243        compLength = entry["compLength"]
1244        tableData = data[offset:offset+compLength]
1245        if compLength < origLength:
1246            tableData = zlib.decompress(tableData)
1247        tables[tag] = tableData
1248    return tables
1249
1250def unpackMetadata(data, decompress=True, parse=True):
1251    header = unpackHeader(data)
1252    data = data[header["metaOffset"]:header["metaOffset"]+header["metaLength"]]
1253    if decompress:
1254        data = zlib.decompress(data)
1255    if parse:
1256        data = ElementTree.fromstring(data)
1257    return data
1258
1259# adapted from fontTools.ttLib.sfnt
1260
1261def calcChecksum(tag, data):
1262    if tag == "head":
1263        data = data[:8] + '\0\0\0\0' + data[12:]
1264    data = padData(data)
1265    a = numpy.fromstring(struct.pack(">l", 0) + data, numpy.int32)
1266    if sys.byteorder != "big":
1267        a = a.byteswap()
1268    return numpy.add.reduce(a)
1269
1270def calcHeadCheckSum(data):
1271    header = unpackHeader(data)
1272    directory = unpackDirectory(data)
1273    tables = unpackTableData(data)
1274    numTables = header["numTables"]
1275    # compile the SFNT header
1276    searchRange, entrySelector, rangeShift = getSearchRange(numTables)
1277    sfntDirectoryData = dict(
1278        sfntVersion=header["flavor"],
1279        numTables=numTables,
1280        searchRange=searchRange,
1281        entrySelector=entrySelector,
1282        rangeShift=rangeShift
1283    )
1284    sfntDirectory = sstruct.pack(sfntDirectoryFormat, sfntDirectoryData)
1285    # create the SFNT directory
1286    sfntEntries = {}
1287    offset = sfntDirectorySize + (sfntDirectoryEntrySize * numTables)
1288    for entry in directory:
1289        tag = entry["tag"]
1290        sfntEntry = SFNTDirectoryEntry()
1291        sfntEntry.tag = tag
1292        sfntEntry.offset = offset
1293        sfntEntry.length = entry["origLength"]
1294        # set the checkSumAdjustment to 0
1295        if tag == "head":
1296            checksum = calcChecksum(tag, tables[tag])
1297        # use the original checksum
1298        else:
1299            checksum = entry["origChecksum"]
1300        sfntEntry.checkSum = checksum
1301        sfntEntries[tag] = sfntEntry
1302    # add the compiled entries to the directory
1303    for tag, sfntEntry in sorted(sfntEntries.items()):
1304        sfntDirectory += sfntEntry.toString()
1305    # compile the table checksums
1306    tags = tables.keys()
1307    checksums = numpy.zeros(len(tags) + 1, numpy.int32)
1308    for index, entry in enumerate(directory):
1309        checksums[index] = sfntEntries[entry["tag"]].checkSum
1310    # add the directory checksum
1311    checksums[-1] = calcChecksum(None, sfntDirectory)
1312    # sum
1313    checksum = numpy.add.reduce(checksums)
1314    # do the required translation
1315    checkSumAdjustment = numpy.array(0xb1b0afbaL - 0x100000000L, numpy.int32) - checksum
1316    # done
1317    return checkSumAdjustment
1318
1319def padData(data):
1320    remainder = len(data) % 4
1321    if remainder:
1322        data += "\0" * (4 - remainder)
1323    return data
1324
1325# -------------------
1326# Execution Functions
1327# -------------------
1328
1329tests = [
1330    ("Header - Size",                   "h-size",               testHeaderSize),
1331    ("Header - Structure",              "h-structure",          testHeaderStructure),
1332    ("Header - Signature",              "h-signature",          testHeaderSignature),
1333    ("Header - Flavor",                 "h-flavor",             testHeaderFlavor),
1334    ("Header - Length",                 "h-length",             testHeaderLength),
1335    ("Header - Reserved",               "h-reserved",           testHeaderReserved),
1336    ("Header - Total sfnt Size",        "h-sfntsize",           testHeaderTotalSFNTSize),
1337    ("Header - Version",                "h-version",            testHeaderMajorVersionAndMinorVersion),
1338    ("Header - Number of Tables",       "h-numtables",          testHeaderNumTables),
1339    ("Directory - Table Order",         "d-order",              testDirectoryTableOrder),
1340    ("Directory - Table Borders",       "d-borders",            testDirectoryBorders),
1341    ("Directory - Compressed Length",   "d-complength",         testDirectoryCompressedLength),
1342    ("Directory - Table Checksums",     "d-checksum",           testDirectoryChecksums),
1343    ("Tables - Start Position",         "t-start",              testTableDataStart),
1344    ("Tables - Padding",                "t-padding",            testTablePadding),
1345    ("Tables - Decompression",          "t-decompression",      testTableDecompression),
1346    ("Tables - Original Length",        "t-origlength",         testDirectoryDecompressedLength),
1347    ("Tables - checkSumAdjustment",     "t-headchecksum",       testHeadCheckSumAdjustment),
1348    ("Tables - DSIG",                   "t-dsig",               testDSIG),
1349    ("Metadata - Offset and Length",    "m-offsetlength",       testMetadataOffsetAndLength),
1350    ("Metadata - Decompression",        "m-decompression",      testMetadataDecompression),
1351    ("Metadata - Original Length",      "m-metaOriglength",     testMetadataDecompressedLength),
1352    ("Metadata - Parse",                "m-parse",              testMetadataParse),
1353    ("Metadata - Structure",            "m-structure",          testMetadataStructure),
1354]
1355
1356def testFont(path, options):
1357    reporter = HTMLReporter()
1358    # log fileinfo
1359    reporter.logFileInfo("file", path)
1360    size = os.path.getsize(path)
1361    size = size * .001
1362    reporter.logFileInfo("file size", str(size) + "k")
1363    # run tests and log results
1364    skip = options.excludeTests
1365    f = open(path, "rb")
1366    data = f.read()
1367    f.close()
1368    shouldStop = False
1369    for title, tag, func in tests:
1370        if tag in skip:
1371            continue
1372        reporter.logTestTitle(title)
1373        shouldStop = func(data, reporter)
1374        if shouldStop:
1375            break
1376    reporter.haveReadError = shouldStop
1377    # log the table, metadata and private data info
1378    if not shouldStop:
1379        directory = unpackDirectory(data)
1380        sorter = [(entry["offset"], entry) for entry in directory]
1381        for offset, entry in sorted(sorter):
1382            reporter.logTableInfo(
1383                tag=entry["tag"],
1384                offset=str(entry["offset"]),
1385                compLength=str(entry["compLength"]),
1386                origLength=str(entry["origLength"]),
1387                origChecksum=str(entry["origChecksum"])
1388            )
1389    # make the output file name
1390    if options.outputFileName is not None:
1391        fileName = options.outputFileName
1392    else:
1393        fileName = os.path.splitext(os.path.basename(path))[0]
1394        fileName += "_report"
1395        fileName += ".html"
1396    # make the output directory
1397    if options.outputDirectory is not None:
1398        directory = options.outputDirectory
1399    else:
1400        directory = os.path.dirname(path)
1401    # write the file
1402    reportPath = os.path.join(directory, fileName)
1403    report = reporter.getReport()
1404    f = open(reportPath, "wb")
1405    f.write(report)
1406    f.close()
1407
1408usage = "%prog [options] fontpath1 fontpath2"
1409
1410description = """This tool examines the structure of one
1411or more WebOTF files and issues a detailed report about
1412the validity of the file structure. It does not validate
1413the wrapped font data. The tests: header, directory.
1414"""
1415
1416def main():
1417    import sys
1418    import optparse
1419    parser = optparse.OptionParser(usage=usage, description=description, version="%prog 0.1b")
1420    parser.add_option("-x", action="append", dest="excludeTests", help="Exclude tests. Supply the identifier listed above.")
1421    parser.add_option("-d", dest="outputDirectory", help="Output directory. The default is to output the report into the same directory as the font file.")
1422    parser.add_option("-o", dest="outputFileName", help="Output file name. The default is \"fontfilename_report.html\".")
1423    parser.set_defaults(excludeTests=[])
1424    (options, args) = parser.parse_args()
1425    if options.runDocTests:
1426        runDocTests()
1427    else:
1428        outputDirectory = options.outputDirectory
1429        if outputDirectory is not None and not os.path.exists(outputDirectory):
1430            print "Directory does not exist:", outputDirectory
1431            sys.exit()
1432        for fontPath in args:
1433            if not os.path.exists(fontPath):
1434                print "File does not exist:", fontPath
1435                sys.exit()
1436            else:
1437                print "Testing: %s..." % fontPath
1438                testFont(fontPath, options)
1439
1440def runDocTests():
1441    import doctest
1442    doctest.testmod(verbose=False)
1443
1444if __name__ == "__main__":
1445    main()
Note: See TracBrowser for help on using the repository browser.