blob: 43bf78b9af4e9b5e5e5aa0a080d6aa12d16f8d0f [file] [log] [blame]
Just7842e561999-12-16 21:34:53 +00001"""ttLib/sfnt.py -- low-level module to deal with the sfnt file format.
2
3Defines two public classes:
4 SFNTReader
5 SFNTWriter
6
7(Normally you don't have to use these classes explicitly; they are
8used automatically by ttLib.TTFont.)
9
10The reading and writing of sfnt files is separated in two distinct
11classes, since whenever to number of tables changes or whenever
12a table's length chages you need to rewrite the whole file anyway.
13"""
14
jvr9be387c2008-03-01 11:43:01 +000015import sys
Just7842e561999-12-16 21:34:53 +000016import struct, sstruct
Just7842e561999-12-16 21:34:53 +000017import os
18
jvr04b32042002-05-14 12:09:10 +000019
Just7842e561999-12-16 21:34:53 +000020class SFNTReader:
21
pabs37e91e772009-02-22 08:55:00 +000022 def __init__(self, file, checkChecksums=1, fontNumber=-1):
Just7842e561999-12-16 21:34:53 +000023 self.file = file
jvrea9dfa92002-05-12 17:14:50 +000024 self.checkChecksums = checkChecksums
Behdad Esfahbod58d74162013-08-15 15:30:55 -040025
26 self.flavor = None
27 self.flavorData = None
28 self.DirectoryEntry = SFNTDirectoryEntry
29 self.sfntVersion = self.file.read(4)
30 self.file.seek(0)
pabs37e91e772009-02-22 08:55:00 +000031 if self.sfntVersion == "ttcf":
Behdad Esfahbod58d74162013-08-15 15:30:55 -040032 sstruct.unpack(ttcHeaderFormat, self.file.read(ttcHeaderSize), self)
pabs37e91e772009-02-22 08:55:00 +000033 assert self.Version == 0x00010000 or self.Version == 0x00020000, "unrecognized TTC version 0x%08x" % self.Version
34 if not 0 <= fontNumber < self.numFonts:
35 from fontTools import ttLib
36 raise ttLib.TTLibError, "specify a font number between 0 and %d (inclusive)" % (self.numFonts - 1)
37 offsetTable = struct.unpack(">%dL" % self.numFonts, self.file.read(self.numFonts * 4))
38 if self.Version == 0x00020000:
39 pass # ignoring version 2.0 signatures
40 self.file.seek(offsetTable[fontNumber])
Behdad Esfahbod58d74162013-08-15 15:30:55 -040041 sstruct.unpack(sfntDirectoryFormat, self.file.read(sfntDirectorySize), self)
42 elif self.sfntVersion == "wOFF":
43 self.flavor = "woff"
44 self.DirectoryEntry = WOFFDirectoryEntry
45 sstruct.unpack(woffDirectoryFormat, self.file.read(woffDirectorySize), self)
46 else:
47 sstruct.unpack(sfntDirectoryFormat, self.file.read(sfntDirectorySize), self)
48
Just7842e561999-12-16 21:34:53 +000049 if self.sfntVersion not in ("\000\001\000\000", "OTTO", "true"):
50 from fontTools import ttLib
51 raise ttLib.TTLibError, "Not a TrueType or OpenType font (bad sfntVersion)"
52 self.tables = {}
53 for i in range(self.numTables):
Behdad Esfahbod58d74162013-08-15 15:30:55 -040054 entry = self.DirectoryEntry()
jvrea9dfa92002-05-12 17:14:50 +000055 entry.fromFile(self.file)
jvrce1d50a2002-05-12 17:02:50 +000056 if entry.length > 0:
57 self.tables[entry.tag] = entry
58 else:
59 # Ignore zero-length tables. This doesn't seem to be documented,
60 # yet it's apparently how the Windows TT rasterizer behaves.
61 # Besides, at least one font has been sighted which actually
62 # *has* a zero-length table.
63 pass
Behdad Esfahbod58d74162013-08-15 15:30:55 -040064
65 # Load flavor data if any
66 if self.flavor == "woff":
67 self.flavorData = WOFFFlavorData(self)
68
Just7842e561999-12-16 21:34:53 +000069 def has_key(self, tag):
70 return self.tables.has_key(tag)
71
72 def keys(self):
73 return self.tables.keys()
74
75 def __getitem__(self, tag):
76 """Fetch the raw table data."""
77 entry = self.tables[tag]
Behdad Esfahbod58d74162013-08-15 15:30:55 -040078 data = entry.loadData (self.file)
jvrea9dfa92002-05-12 17:14:50 +000079 if self.checkChecksums:
Just7842e561999-12-16 21:34:53 +000080 if tag == 'head':
81 # Beh: we have to special-case the 'head' table.
jvrea9dfa92002-05-12 17:14:50 +000082 checksum = calcChecksum(data[:8] + '\0\0\0\0' + data[12:])
Just7842e561999-12-16 21:34:53 +000083 else:
jvrea9dfa92002-05-12 17:14:50 +000084 checksum = calcChecksum(data)
85 if self.checkChecksums > 1:
Just7842e561999-12-16 21:34:53 +000086 # Be obnoxious, and barf when it's wrong
87 assert checksum == entry.checksum, "bad checksum for '%s' table" % tag
88 elif checksum <> entry.checkSum:
89 # Be friendly, and just print a warning.
90 print "bad checksum for '%s' table" % tag
91 return data
92
jvrf7074632002-05-04 22:04:02 +000093 def __delitem__(self, tag):
94 del self.tables[tag]
95
Just7842e561999-12-16 21:34:53 +000096 def close(self):
97 self.file.close()
98
99
100class SFNTWriter:
101
Behdad Esfahbodb0dc6df2013-08-15 17:39:16 -0400102 def __init__(self, file, numTables, sfntVersion="\000\001\000\000",
103 flavor=None, flavorData=None):
Just7842e561999-12-16 21:34:53 +0000104 self.file = file
105 self.numTables = numTables
106 self.sfntVersion = sfntVersion
Behdad Esfahbodb0dc6df2013-08-15 17:39:16 -0400107 self.flavor = flavor
108 self.flavorData = flavorData
109
110 if self.flavor == "woff":
111 self.directoryFormat = woffDirectoryFormat
112 self.directorySize = woffDirectorySize
113 self.DirectoryEntry = WOFFDirectoryEntry
114
115 self.signature = "wOFF"
116 else:
117 assert not self.flavor, "Unknown flavor '%s'" % self.flavor
118 self.directoryFormat = sfntDirectoryFormat
119 self.directorySize = sfntDirectorySize
120 self.DirectoryEntry = SFNTDirectoryEntry
121
122 self.searchRange, self.entrySelector, self.rangeShift = getSearchRange(numTables)
123
124 self.nextTableOffset = self.directorySize + numTables * self.DirectoryEntry.formatSize
Just7842e561999-12-16 21:34:53 +0000125 # clear out directory area
126 self.file.seek(self.nextTableOffset)
Behdad Esfahbodb0dc6df2013-08-15 17:39:16 -0400127 # make sure we're actually where we want to be. (old cStringIO bug)
Just7842e561999-12-16 21:34:53 +0000128 self.file.write('\0' * (self.nextTableOffset - self.file.tell()))
129 self.tables = {}
130
131 def __setitem__(self, tag, data):
132 """Write raw table data to disk."""
Behdad Esfahbodb0dc6df2013-08-15 17:39:16 -0400133 reuse = False
Just7842e561999-12-16 21:34:53 +0000134 if self.tables.has_key(tag):
135 # We've written this table to file before. If the length
jvr04b32042002-05-14 12:09:10 +0000136 # of the data is still the same, we allow overwriting it.
Just7842e561999-12-16 21:34:53 +0000137 entry = self.tables[tag]
Behdad Esfahbodb0dc6df2013-08-15 17:39:16 -0400138 assert not hasattr(entry.__class__, 'encodeData')
Just7842e561999-12-16 21:34:53 +0000139 if len(data) <> entry.length:
140 from fontTools import ttLib
141 raise ttLib.TTLibError, "cannot rewrite '%s' table: length does not match directory entry" % tag
Behdad Esfahbodb0dc6df2013-08-15 17:39:16 -0400142 reuse = True
Just7842e561999-12-16 21:34:53 +0000143 else:
Behdad Esfahbodb0dc6df2013-08-15 17:39:16 -0400144 entry = self.DirectoryEntry()
Just7842e561999-12-16 21:34:53 +0000145 entry.tag = tag
Behdad Esfahbodb0dc6df2013-08-15 17:39:16 -0400146
147 if tag == 'head':
148 entry.checkSum = calcChecksum(data[:8] + '\0\0\0\0' + data[12:])
149 self.headTable = data
150 entry.uncompressed = True
151 else:
152 entry.checkSum = calcChecksum(data)
153
154 entry.offset = self.nextTableOffset
155 entry.saveData (self.file, data)
156
157 if not reuse:
158 self.nextTableOffset = self.nextTableOffset + ((entry.length + 3) & ~3)
159
jvrc63ac642008-06-17 20:41:15 +0000160 # Add NUL bytes to pad the table data to a 4-byte boundary.
161 # Don't depend on f.seek() as we need to add the padding even if no
162 # subsequent write follows (seek is lazy), ie. after the final table
163 # in the font.
Just7842e561999-12-16 21:34:53 +0000164 self.file.write('\0' * (self.nextTableOffset - self.file.tell()))
jvrc63ac642008-06-17 20:41:15 +0000165 assert self.nextTableOffset == self.file.tell()
Just7842e561999-12-16 21:34:53 +0000166
Just7842e561999-12-16 21:34:53 +0000167 self.tables[tag] = entry
168
jvr28ae1962004-11-16 10:37:59 +0000169 def close(self):
Just7842e561999-12-16 21:34:53 +0000170 """All tables must have been written to disk. Now write the
171 directory.
172 """
173 tables = self.tables.items()
174 tables.sort()
175 if len(tables) <> self.numTables:
176 from fontTools import ttLib
177 raise ttLib.TTLibError, "wrong number of tables; expected %d, found %d" % (self.numTables, len(tables))
Behdad Esfahbodb0dc6df2013-08-15 17:39:16 -0400178
179 if self.flavor == "woff":
180 self.signature = "wOFF"
181 self.reserved = 0
182
183 self.totalSfntSize = 12
184 self.totalSfntSize += 16 * len(tables)
185 for tag, entry in tables:
186 self.totalSfntSize += (entry.origLength + 3) & ~3
187
188 data = self.flavorData if self.flavorData else WOFFFlavorData()
189 if data.majorVersion != None and data.minorVersion != None:
190 self.majorVersion = data.majorVersion
191 self.minorVersion = data.minorVersion
192 else:
193 if hasattr(self, 'headTable'):
194 self.majorVersion, self.minorVersion = struct.unpack(">HH", self.headTable[4:8])
195 else:
196 self.majorVersion = self.minorVersion = 0
197 if data.metaData:
198 self.metaOrigLength = len(data.metaData)
199 self.file.seek(0,2)
200 self.metaOffset = self.file.tell()
201 compressedMetaData = zlib.compress(data.metaData)
202 self.metaLength = len(compressedMetaData)
203 self.file.write(compressedMetaData)
204 else:
205 self.metaOffset = self.metaLength = self.metaOrigLength = 0
206 if data.privData:
207 self.file.seek(0,2)
208 off = self.file.tell()
209 paddedOff = (off + 3) & ~3
210 self.file.write('\0' * (paddedOff - off))
211 self.privOffset = self.file.tell()
212 self.privLength = len(data.privData)
213 self.file.write(data.privData)
214 else:
215 self.privOffset = self.privLength = 0
216
217 self.file.seek(0,2)
218 self.length = self.file.tell()
219
220 else:
221 assert not self.flavor, "Unknown flavor '%s'" % self.flavor
222 pass
Just7842e561999-12-16 21:34:53 +0000223
Behdad Esfahbodb0dc6df2013-08-15 17:39:16 -0400224 directory = sstruct.pack(self.directoryFormat, self)
Just7842e561999-12-16 21:34:53 +0000225
Behdad Esfahbodb0dc6df2013-08-15 17:39:16 -0400226 self.file.seek(self.directorySize)
jvrf509c0f2003-08-22 19:38:37 +0000227 seenHead = 0
Just7842e561999-12-16 21:34:53 +0000228 for tag, entry in tables:
jvrf509c0f2003-08-22 19:38:37 +0000229 if tag == "head":
230 seenHead = 1
jvrea9dfa92002-05-12 17:14:50 +0000231 directory = directory + entry.toString()
jvrf509c0f2003-08-22 19:38:37 +0000232 if seenHead:
jvr91bca422012-10-18 12:49:22 +0000233 self.writeMasterChecksum(directory)
Just7842e561999-12-16 21:34:53 +0000234 self.file.seek(0)
235 self.file.write(directory)
jvr91bca422012-10-18 12:49:22 +0000236
237 def _calcMasterChecksum(self, directory):
Just7842e561999-12-16 21:34:53 +0000238 # calculate checkSumAdjustment
239 tags = self.tables.keys()
jvr91bca422012-10-18 12:49:22 +0000240 checksums = []
Just7842e561999-12-16 21:34:53 +0000241 for i in range(len(tags)):
jvr91bca422012-10-18 12:49:22 +0000242 checksums.append(self.tables[tags[i]].checkSum)
243
Behdad Esfahbodb0dc6df2013-08-15 17:39:16 -0400244 # TODO(behdad) I'm fairly sure the checksum for woff is not working correctly.
245 # Haven't debugged.
246 if self.DirectoryEntry != SFNTDirectoryEntry:
247 # Create a SFNT directory for checksum calculation purposes
248 self.searchRange, self.entrySelector, self.rangeShift = getSearchRange(self.numTables)
249 directory = sstruct.pack(sfntDirectoryFormat, self)
250 tables = self.tables.items()
251 tables.sort()
252 for tag, entry in tables:
253 sfntEntry = SFNTDirectoryEntry()
254 for item in ['tag', 'checkSum', 'offset', 'length']:
255 setattr(sfntEntry, item, getattr(entry, item))
256 directory = directory + sfntEntry.toString()
257
Just7842e561999-12-16 21:34:53 +0000258 directory_end = sfntDirectorySize + len(self.tables) * sfntDirectoryEntrySize
259 assert directory_end == len(directory)
jvr91bca422012-10-18 12:49:22 +0000260
261 checksums.append(calcChecksum(directory))
262 checksum = sum(checksums) & 0xffffffff
Just7842e561999-12-16 21:34:53 +0000263 # BiboAfba!
jvr91bca422012-10-18 12:49:22 +0000264 checksumadjustment = (0xB1B0AFBA - checksum) & 0xffffffff
265 return checksumadjustment
266
267 def writeMasterChecksum(self, directory):
268 checksumadjustment = self._calcMasterChecksum(directory)
Just7842e561999-12-16 21:34:53 +0000269 # write the checksum to the file
270 self.file.seek(self.tables['head'].offset + 8)
pabs30e2aece2009-03-24 09:42:15 +0000271 self.file.write(struct.pack(">L", checksumadjustment))
jvr1ebda672008-03-08 20:29:30 +0000272
Just7842e561999-12-16 21:34:53 +0000273
274# -- sfnt directory helpers and cruft
275
pabs37e91e772009-02-22 08:55:00 +0000276ttcHeaderFormat = """
277 > # big endian
278 TTCTag: 4s # "ttcf"
279 Version: L # 0x00010000 or 0x00020000
280 numFonts: L # number of fonts
281 # OffsetTable[numFonts]: L # array with offsets from beginning of file
282 # ulDsigTag: L # version 2.0 only
283 # ulDsigLength: L # version 2.0 only
284 # ulDsigOffset: L # version 2.0 only
285"""
286
287ttcHeaderSize = sstruct.calcsize(ttcHeaderFormat)
288
Just7842e561999-12-16 21:34:53 +0000289sfntDirectoryFormat = """
290 > # big endian
jvrb0e5f292002-05-13 11:21:48 +0000291 sfntVersion: 4s
292 numTables: H # number of tables
293 searchRange: H # (max2 <= numTables)*16
294 entrySelector: H # log2(max2 <= numTables)
295 rangeShift: H # numTables*16-searchRange
Just7842e561999-12-16 21:34:53 +0000296"""
297
298sfntDirectorySize = sstruct.calcsize(sfntDirectoryFormat)
299
300sfntDirectoryEntryFormat = """
301 > # big endian
jvrb0e5f292002-05-13 11:21:48 +0000302 tag: 4s
pabs30e2aece2009-03-24 09:42:15 +0000303 checkSum: L
304 offset: L
305 length: L
Just7842e561999-12-16 21:34:53 +0000306"""
307
308sfntDirectoryEntrySize = sstruct.calcsize(sfntDirectoryEntryFormat)
309
Behdad Esfahbod58d74162013-08-15 15:30:55 -0400310woffDirectoryFormat = """
311 > # big endian
312 signature: 4s # "wOFF"
313 sfntVersion: 4s
314 length: L # total woff file size
315 numTables: H # number of tables
316 reserved: H # set to 0
317 totalSfntSize: L # uncompressed size
318 majorVersion: H # major version of WOFF file
319 minorVersion: H # minor version of WOFF file
320 metaOffset: L # offset to metadata block
321 metaLength: L # length of compressed metadata
322 metaOrigLength: L # length of uncompressed metadata
323 privOffset: L # offset to private data block
324 privLength: L # length of private data block
325"""
326
327woffDirectorySize = sstruct.calcsize(woffDirectoryFormat)
328
329woffDirectoryEntryFormat = """
330 > # big endian
331 tag: 4s
332 offset: L
333 length: L # compressed length
334 origLength: L # original length
Behdad Esfahbodb0dc6df2013-08-15 17:39:16 -0400335 checkSum: L # original checksum
Behdad Esfahbod58d74162013-08-15 15:30:55 -0400336"""
337
338woffDirectoryEntrySize = sstruct.calcsize(woffDirectoryEntryFormat)
339
340
341class DirectoryEntry:
Just7842e561999-12-16 21:34:53 +0000342
Behdad Esfahbodb0dc6df2013-08-15 17:39:16 -0400343 def __init__(self):
344 self.uncompressed = False # if True, always embed entry raw
345
jvrea9dfa92002-05-12 17:14:50 +0000346 def fromFile(self, file):
Behdad Esfahbod58d74162013-08-15 15:30:55 -0400347 sstruct.unpack(self.format, file.read(self.formatSize), self)
Just7842e561999-12-16 21:34:53 +0000348
jvrea9dfa92002-05-12 17:14:50 +0000349 def fromString(self, str):
Behdad Esfahbod58d74162013-08-15 15:30:55 -0400350 sstruct.unpack(self.format, str, self)
Just7842e561999-12-16 21:34:53 +0000351
jvrea9dfa92002-05-12 17:14:50 +0000352 def toString(self):
Behdad Esfahbod58d74162013-08-15 15:30:55 -0400353 return sstruct.pack(self.format, self)
Just7842e561999-12-16 21:34:53 +0000354
355 def __repr__(self):
356 if hasattr(self, "tag"):
Behdad Esfahbod58d74162013-08-15 15:30:55 -0400357 return "<%s '%s' at %x>" % (self.__class__.__name__, self.tag, id(self))
Just7842e561999-12-16 21:34:53 +0000358 else:
Behdad Esfahbod58d74162013-08-15 15:30:55 -0400359 return "<%s at %x>" % (self.__class__.__name__, id(self))
360
361 def loadData(self, file):
362 file.seek(self.offset)
363 data = file.read(self.length)
364 assert len(data) == self.length
Behdad Esfahbodb0dc6df2013-08-15 17:39:16 -0400365 if hasattr(self.__class__, 'decodeData'):
366 data = self.decodeData(data)
367 return data
368
369 def saveData(self, file, data):
370 if hasattr(self.__class__, 'encodeData'):
371 data = self.encodeData(data)
372 self.length = len(data)
373 file.seek(self.offset)
374 file.write(data)
Behdad Esfahbod58d74162013-08-15 15:30:55 -0400375
376 def decodeData(self, rawData):
377 return rawData
378
Behdad Esfahbodb0dc6df2013-08-15 17:39:16 -0400379 def encodeData(self, data):
380 return data
381
Behdad Esfahbod58d74162013-08-15 15:30:55 -0400382class SFNTDirectoryEntry(DirectoryEntry):
383
384 format = sfntDirectoryEntryFormat
385 formatSize = sfntDirectoryEntrySize
386
387class WOFFDirectoryEntry(DirectoryEntry):
388
389 format = woffDirectoryEntryFormat
390 formatSize = woffDirectoryEntrySize
Behdad Esfahbodb0dc6df2013-08-15 17:39:16 -0400391 zlibCompressionLevel = 6
Behdad Esfahbod58d74162013-08-15 15:30:55 -0400392
393 def decodeData(self, rawData):
394 import zlib
395 if self.length == self.origLength:
396 data = rawData
397 else:
398 assert self.length < self.origLength
399 data = zlib.decompress(rawData)
400 assert len (data) == self.origLength
401 return data
402
Behdad Esfahbodb0dc6df2013-08-15 17:39:16 -0400403 def encodeData(self, data):
404 import zlib
405 self.origLength = len(data)
406 if not self.uncompressed:
407 compressedData = zlib.compress(data, self.zlibCompressionLevel)
408 if self.uncompressed or len(compressedData) >= self.origLength:
409 # Encode uncompressed
410 rawData = data
411 self.length = self.origLength
412 else:
413 rawData = compressedData
414 self.length = len(rawData)
415 return rawData
416
Behdad Esfahbod58d74162013-08-15 15:30:55 -0400417class WOFFFlavorData():
418
Behdad Esfahbodb0dc6df2013-08-15 17:39:16 -0400419 Flavor = 'woff'
420
Behdad Esfahbod58d74162013-08-15 15:30:55 -0400421 def __init__(self, reader=None):
422 self.majorVersion = None
423 self.minorVersion = None
424 self.metaData = None
425 self.privData = None
426 if reader:
427 self.majorVersion = reader.majorVersion
428 self.minorVersion = reader.minorVersion
429 if reader.metaLength:
430 reader.file.seek(reader.metaOffset)
431 rawData = read.file.read(reader.metaLength)
432 assert len(rawData) == reader.metaLength
433 data = zlib.decompress(rawData)
434 assert len(data) == reader.metaOrigLength
435 self.metaData = data
436 if reader.privLength:
437 reader.file.seek(reader.privOffset)
438 data = read.file.read(reader.privLength)
439 assert len(data) == reader.privLength
440 self.privData = data
Just7842e561999-12-16 21:34:53 +0000441
442
jvr91bca422012-10-18 12:49:22 +0000443def calcChecksum(data):
Just7842e561999-12-16 21:34:53 +0000444 """Calculate the checksum for an arbitrary block of data.
445 Optionally takes a 'start' argument, which allows you to
446 calculate a checksum in chunks by feeding it a previous
447 result.
448
449 If the data length is not a multiple of four, it assumes
450 it is to be padded with null byte.
jvr91bca422012-10-18 12:49:22 +0000451
452 >>> print calcChecksum("abcd")
453 1633837924
454 >>> print calcChecksum("abcdxyz")
455 3655064932
Just7842e561999-12-16 21:34:53 +0000456 """
Just7842e561999-12-16 21:34:53 +0000457 remainder = len(data) % 4
458 if remainder:
jvr91bca422012-10-18 12:49:22 +0000459 data += "\0" * (4 - remainder)
460 value = 0
461 blockSize = 4096
462 assert blockSize % 4 == 0
463 for i in xrange(0, len(data), blockSize):
464 block = data[i:i+blockSize]
465 longs = struct.unpack(">%dL" % (len(block) // 4), block)
466 value = (value + sum(longs)) & 0xffffffff
467 return value
Just7842e561999-12-16 21:34:53 +0000468
469
jvrea9dfa92002-05-12 17:14:50 +0000470def maxPowerOfTwo(x):
Just7842e561999-12-16 21:34:53 +0000471 """Return the highest exponent of two, so that
472 (2 ** exponent) <= x
473 """
474 exponent = 0
475 while x:
476 x = x >> 1
477 exponent = exponent + 1
Justfdea99d2000-08-23 12:34:44 +0000478 return max(exponent - 1, 0)
Just7842e561999-12-16 21:34:53 +0000479
480
jvrea9dfa92002-05-12 17:14:50 +0000481def getSearchRange(n):
Just7842e561999-12-16 21:34:53 +0000482 """Calculate searchRange, entrySelector, rangeShift for the
483 sfnt directory. 'n' is the number of tables.
484 """
485 # This stuff needs to be stored in the file, because?
486 import math
jvrea9dfa92002-05-12 17:14:50 +0000487 exponent = maxPowerOfTwo(n)
Just7842e561999-12-16 21:34:53 +0000488 searchRange = (2 ** exponent) * 16
489 entrySelector = exponent
490 rangeShift = n * 16 - searchRange
491 return searchRange, entrySelector, rangeShift
492
jvr91bca422012-10-18 12:49:22 +0000493
494if __name__ == "__main__":
495 import doctest
496 doctest.testmod()