1from __future__ import print_function, division, absolute_import, unicode_literals 2from fontTools.misc.py23 import * 3from fontTools import ttLib 4from fontTools.ttLib import woff2 5from fontTools.ttLib.woff2 import ( 6 WOFF2Reader, woff2DirectorySize, woff2DirectoryFormat, 7 woff2FlagsSize, woff2UnknownTagSize, woff2Base128MaxSize, WOFF2DirectoryEntry, 8 getKnownTagIndex, packBase128, base128Size, woff2UnknownTagIndex, 9 WOFF2FlavorData, woff2TransformedTableTags, WOFF2GlyfTable, WOFF2LocaTable, 10 WOFF2HmtxTable, WOFF2Writer, unpackBase128, unpack255UShort, pack255UShort) 11import unittest 12from fontTools.misc import sstruct 13from fontTools import fontBuilder 14from fontTools.pens.ttGlyphPen import TTGlyphPen 15import struct 16import os 17import random 18import copy 19from collections import OrderedDict 20from functools import partial 21import pytest 22 23haveBrotli = False 24try: 25 import brotli 26 haveBrotli = True 27except ImportError: 28 pass 29 30 31# Python 3 renamed 'assertRaisesRegexp' to 'assertRaisesRegex', and fires 32# deprecation warnings if a program uses the old name. 33if not hasattr(unittest.TestCase, 'assertRaisesRegex'): 34 unittest.TestCase.assertRaisesRegex = unittest.TestCase.assertRaisesRegexp 35 36 37current_dir = os.path.abspath(os.path.dirname(os.path.realpath(__file__))) 38data_dir = os.path.join(current_dir, 'data') 39TTX = os.path.join(data_dir, 'TestTTF-Regular.ttx') 40OTX = os.path.join(data_dir, 'TestOTF-Regular.otx') 41METADATA = os.path.join(data_dir, 'test_woff2_metadata.xml') 42 43TT_WOFF2 = BytesIO() 44CFF_WOFF2 = BytesIO() 45 46 47def setUpModule(): 48 if not haveBrotli: 49 raise unittest.SkipTest("No module named brotli") 50 assert os.path.exists(TTX) 51 assert os.path.exists(OTX) 52 # import TT-flavoured test font and save it as WOFF2 53 ttf = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False) 54 ttf.importXML(TTX) 55 ttf.flavor = "woff2" 56 ttf.save(TT_WOFF2, reorderTables=None) 57 # import CFF-flavoured test font and save it as WOFF2 58 otf = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False) 59 otf.importXML(OTX) 60 otf.flavor = "woff2" 61 otf.save(CFF_WOFF2, reorderTables=None) 62 63 64class WOFF2ReaderTest(unittest.TestCase): 65 66 @classmethod 67 def setUpClass(cls): 68 cls.file = BytesIO(CFF_WOFF2.getvalue()) 69 cls.font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False) 70 cls.font.importXML(OTX) 71 72 def setUp(self): 73 self.file.seek(0) 74 75 def test_bad_signature(self): 76 with self.assertRaisesRegex(ttLib.TTLibError, 'bad signature'): 77 WOFF2Reader(BytesIO(b"wOFF")) 78 79 def test_not_enough_data_header(self): 80 incomplete_header = self.file.read(woff2DirectorySize - 1) 81 with self.assertRaisesRegex(ttLib.TTLibError, 'not enough data'): 82 WOFF2Reader(BytesIO(incomplete_header)) 83 84 def test_incorrect_compressed_size(self): 85 data = self.file.read(woff2DirectorySize) 86 header = sstruct.unpack(woff2DirectoryFormat, data) 87 header['totalCompressedSize'] = 0 88 data = sstruct.pack(woff2DirectoryFormat, header) 89 with self.assertRaises((brotli.error, ttLib.TTLibError)): 90 WOFF2Reader(BytesIO(data + self.file.read())) 91 92 def test_incorrect_uncompressed_size(self): 93 decompress_backup = brotli.decompress 94 brotli.decompress = lambda data: b"" # return empty byte string 95 with self.assertRaisesRegex(ttLib.TTLibError, 'unexpected size for decompressed'): 96 WOFF2Reader(self.file) 97 brotli.decompress = decompress_backup 98 99 def test_incorrect_file_size(self): 100 data = self.file.read(woff2DirectorySize) 101 header = sstruct.unpack(woff2DirectoryFormat, data) 102 header['length'] -= 1 103 data = sstruct.pack(woff2DirectoryFormat, header) 104 with self.assertRaisesRegex( 105 ttLib.TTLibError, "doesn't match the actual file size"): 106 WOFF2Reader(BytesIO(data + self.file.read())) 107 108 def test_num_tables(self): 109 tags = [t for t in self.font.keys() if t not in ('GlyphOrder', 'DSIG')] 110 data = self.file.read(woff2DirectorySize) 111 header = sstruct.unpack(woff2DirectoryFormat, data) 112 self.assertEqual(header['numTables'], len(tags)) 113 114 def test_table_tags(self): 115 tags = set([t for t in self.font.keys() if t not in ('GlyphOrder', 'DSIG')]) 116 reader = WOFF2Reader(self.file) 117 self.assertEqual(set(reader.keys()), tags) 118 119 def test_get_normal_tables(self): 120 woff2Reader = WOFF2Reader(self.file) 121 specialTags = woff2TransformedTableTags + ('head', 'GlyphOrder', 'DSIG') 122 for tag in [t for t in self.font.keys() if t not in specialTags]: 123 origData = self.font.getTableData(tag) 124 decompressedData = woff2Reader[tag] 125 self.assertEqual(origData, decompressedData) 126 127 def test_reconstruct_unknown(self): 128 reader = WOFF2Reader(self.file) 129 with self.assertRaisesRegex(ttLib.TTLibError, 'transform for table .* unknown'): 130 reader.reconstructTable('head') 131 132 133class WOFF2ReaderTTFTest(WOFF2ReaderTest): 134 """ Tests specific to TT-flavored fonts. """ 135 136 @classmethod 137 def setUpClass(cls): 138 cls.file = BytesIO(TT_WOFF2.getvalue()) 139 cls.font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False) 140 cls.font.importXML(TTX) 141 142 def setUp(self): 143 self.file.seek(0) 144 145 def test_reconstruct_glyf(self): 146 woff2Reader = WOFF2Reader(self.file) 147 reconstructedData = woff2Reader['glyf'] 148 self.assertEqual(self.font.getTableData('glyf'), reconstructedData) 149 150 def test_reconstruct_loca(self): 151 woff2Reader = WOFF2Reader(self.file) 152 reconstructedData = woff2Reader['loca'] 153 self.font.getTableData("glyf") # 'glyf' needs to be compiled before 'loca' 154 self.assertEqual(self.font.getTableData('loca'), reconstructedData) 155 self.assertTrue(hasattr(woff2Reader.tables['glyf'], 'data')) 156 157 def test_reconstruct_loca_not_match_orig_size(self): 158 reader = WOFF2Reader(self.file) 159 reader.tables['loca'].origLength -= 1 160 with self.assertRaisesRegex( 161 ttLib.TTLibError, "'loca' table doesn't match original size"): 162 reader.reconstructTable('loca') 163 164 165def normalise_table(font, tag, padding=4): 166 """ Return normalised table data. Keep 'font' instance unmodified. """ 167 assert tag in ('glyf', 'loca', 'head') 168 assert tag in font 169 if tag == 'head': 170 origHeadFlags = font['head'].flags 171 font['head'].flags |= (1 << 11) 172 tableData = font['head'].compile(font) 173 if font.sfntVersion in ("\x00\x01\x00\x00", "true"): 174 assert {'glyf', 'loca', 'head'}.issubset(font.keys()) 175 origIndexFormat = font['head'].indexToLocFormat 176 if hasattr(font['loca'], 'locations'): 177 origLocations = font['loca'].locations[:] 178 else: 179 origLocations = [] 180 glyfTable = ttLib.newTable('glyf') 181 glyfTable.decompile(font.getTableData('glyf'), font) 182 glyfTable.padding = padding 183 if tag == 'glyf': 184 tableData = glyfTable.compile(font) 185 elif tag == 'loca': 186 glyfTable.compile(font) 187 tableData = font['loca'].compile(font) 188 if tag == 'head': 189 glyfTable.compile(font) 190 font['loca'].compile(font) 191 tableData = font['head'].compile(font) 192 font['head'].indexToLocFormat = origIndexFormat 193 font['loca'].set(origLocations) 194 if tag == 'head': 195 font['head'].flags = origHeadFlags 196 return tableData 197 198 199def normalise_font(font, padding=4): 200 """ Return normalised font data. Keep 'font' instance unmodified. """ 201 # drop DSIG but keep a copy 202 DSIG_copy = copy.deepcopy(font['DSIG']) 203 del font['DSIG'] 204 # ovverride TTFont attributes 205 origFlavor = font.flavor 206 origRecalcBBoxes = font.recalcBBoxes 207 origRecalcTimestamp = font.recalcTimestamp 208 origLazy = font.lazy 209 font.flavor = None 210 font.recalcBBoxes = False 211 font.recalcTimestamp = False 212 font.lazy = True 213 # save font to temporary stream 214 infile = BytesIO() 215 font.save(infile) 216 infile.seek(0) 217 # reorder tables alphabetically 218 outfile = BytesIO() 219 reader = ttLib.sfnt.SFNTReader(infile) 220 writer = ttLib.sfnt.SFNTWriter( 221 outfile, len(reader.tables), reader.sfntVersion, reader.flavor, reader.flavorData) 222 for tag in sorted(reader.keys()): 223 if tag in woff2TransformedTableTags + ('head',): 224 writer[tag] = normalise_table(font, tag, padding) 225 else: 226 writer[tag] = reader[tag] 227 writer.close() 228 # restore font attributes 229 font['DSIG'] = DSIG_copy 230 font.flavor = origFlavor 231 font.recalcBBoxes = origRecalcBBoxes 232 font.recalcTimestamp = origRecalcTimestamp 233 font.lazy = origLazy 234 return outfile.getvalue() 235 236 237class WOFF2DirectoryEntryTest(unittest.TestCase): 238 239 def setUp(self): 240 self.entry = WOFF2DirectoryEntry() 241 242 def test_not_enough_data_table_flags(self): 243 with self.assertRaisesRegex(ttLib.TTLibError, "can't read table 'flags'"): 244 self.entry.fromString(b"") 245 246 def test_not_enough_data_table_tag(self): 247 incompleteData = bytearray([0x3F, 0, 0, 0]) 248 with self.assertRaisesRegex(ttLib.TTLibError, "can't read table 'tag'"): 249 self.entry.fromString(bytes(incompleteData)) 250 251 def test_loca_zero_transformLength(self): 252 data = bytechr(getKnownTagIndex('loca')) # flags 253 data += packBase128(random.randint(1, 100)) # origLength 254 data += packBase128(1) # non-zero transformLength 255 with self.assertRaisesRegex( 256 ttLib.TTLibError, "transformLength of the 'loca' table must be 0"): 257 self.entry.fromString(data) 258 259 def test_fromFile(self): 260 unknownTag = Tag('ZZZZ') 261 data = bytechr(getKnownTagIndex(unknownTag)) 262 data += unknownTag.tobytes() 263 data += packBase128(random.randint(1, 100)) 264 expectedPos = len(data) 265 f = BytesIO(data + b'\0'*100) 266 self.entry.fromFile(f) 267 self.assertEqual(f.tell(), expectedPos) 268 269 def test_transformed_toString(self): 270 self.entry.tag = Tag('glyf') 271 self.entry.flags = getKnownTagIndex(self.entry.tag) 272 self.entry.origLength = random.randint(101, 200) 273 self.entry.length = random.randint(1, 100) 274 expectedSize = (woff2FlagsSize + base128Size(self.entry.origLength) + 275 base128Size(self.entry.length)) 276 data = self.entry.toString() 277 self.assertEqual(len(data), expectedSize) 278 279 def test_known_toString(self): 280 self.entry.tag = Tag('head') 281 self.entry.flags = getKnownTagIndex(self.entry.tag) 282 self.entry.origLength = 54 283 expectedSize = (woff2FlagsSize + base128Size(self.entry.origLength)) 284 data = self.entry.toString() 285 self.assertEqual(len(data), expectedSize) 286 287 def test_unknown_toString(self): 288 self.entry.tag = Tag('ZZZZ') 289 self.entry.flags = woff2UnknownTagIndex 290 self.entry.origLength = random.randint(1, 100) 291 expectedSize = (woff2FlagsSize + woff2UnknownTagSize + 292 base128Size(self.entry.origLength)) 293 data = self.entry.toString() 294 self.assertEqual(len(data), expectedSize) 295 296 def test_glyf_loca_transform_flags(self): 297 for tag in ("glyf", "loca"): 298 entry = WOFF2DirectoryEntry() 299 entry.tag = Tag(tag) 300 entry.flags = getKnownTagIndex(entry.tag) 301 302 self.assertEqual(entry.transformVersion, 0) 303 self.assertTrue(entry.transformed) 304 305 entry.transformed = False 306 307 self.assertEqual(entry.transformVersion, 3) 308 self.assertEqual(entry.flags & 0b11000000, (3 << 6)) 309 self.assertFalse(entry.transformed) 310 311 def test_other_transform_flags(self): 312 entry = WOFF2DirectoryEntry() 313 entry.tag = Tag('ZZZZ') 314 entry.flags = woff2UnknownTagIndex 315 316 self.assertEqual(entry.transformVersion, 0) 317 self.assertFalse(entry.transformed) 318 319 entry.transformed = True 320 321 self.assertEqual(entry.transformVersion, 1) 322 self.assertEqual(entry.flags & 0b11000000, (1 << 6)) 323 self.assertTrue(entry.transformed) 324 325 326class DummyReader(WOFF2Reader): 327 328 def __init__(self, file, checkChecksums=1, fontNumber=-1): 329 self.file = file 330 for attr in ('majorVersion', 'minorVersion', 'metaOffset', 'metaLength', 331 'metaOrigLength', 'privLength', 'privOffset'): 332 setattr(self, attr, 0) 333 self.tables = {} 334 335 336class WOFF2FlavorDataTest(unittest.TestCase): 337 338 @classmethod 339 def setUpClass(cls): 340 assert os.path.exists(METADATA) 341 with open(METADATA, 'rb') as f: 342 cls.xml_metadata = f.read() 343 cls.compressed_metadata = brotli.compress(cls.xml_metadata, mode=brotli.MODE_TEXT) 344 # make random byte strings; font data must be 4-byte aligned 345 cls.fontdata = bytes(bytearray(random.sample(range(0, 256), 80))) 346 cls.privData = bytes(bytearray(random.sample(range(0, 256), 20))) 347 348 def setUp(self): 349 self.file = BytesIO(self.fontdata) 350 self.file.seek(0, 2) 351 352 def test_get_metaData_no_privData(self): 353 self.file.write(self.compressed_metadata) 354 reader = DummyReader(self.file) 355 reader.metaOffset = len(self.fontdata) 356 reader.metaLength = len(self.compressed_metadata) 357 reader.metaOrigLength = len(self.xml_metadata) 358 flavorData = WOFF2FlavorData(reader) 359 self.assertEqual(self.xml_metadata, flavorData.metaData) 360 361 def test_get_privData_no_metaData(self): 362 self.file.write(self.privData) 363 reader = DummyReader(self.file) 364 reader.privOffset = len(self.fontdata) 365 reader.privLength = len(self.privData) 366 flavorData = WOFF2FlavorData(reader) 367 self.assertEqual(self.privData, flavorData.privData) 368 369 def test_get_metaData_and_privData(self): 370 self.file.write(self.compressed_metadata + self.privData) 371 reader = DummyReader(self.file) 372 reader.metaOffset = len(self.fontdata) 373 reader.metaLength = len(self.compressed_metadata) 374 reader.metaOrigLength = len(self.xml_metadata) 375 reader.privOffset = reader.metaOffset + reader.metaLength 376 reader.privLength = len(self.privData) 377 flavorData = WOFF2FlavorData(reader) 378 self.assertEqual(self.xml_metadata, flavorData.metaData) 379 self.assertEqual(self.privData, flavorData.privData) 380 381 def test_get_major_minorVersion(self): 382 reader = DummyReader(self.file) 383 reader.majorVersion = reader.minorVersion = 1 384 flavorData = WOFF2FlavorData(reader) 385 self.assertEqual(flavorData.majorVersion, 1) 386 self.assertEqual(flavorData.minorVersion, 1) 387 388 def test_mutually_exclusive_args(self): 389 msg = "arguments are mutually exclusive" 390 reader = DummyReader(self.file) 391 with self.assertRaisesRegex(TypeError, msg): 392 WOFF2FlavorData(reader, transformedTables={"hmtx"}) 393 with self.assertRaisesRegex(TypeError, msg): 394 WOFF2FlavorData(reader, data=WOFF2FlavorData()) 395 396 def test_transformedTables_default(self): 397 flavorData = WOFF2FlavorData() 398 self.assertEqual(flavorData.transformedTables, set(woff2TransformedTableTags)) 399 400 def test_transformedTables_invalid(self): 401 msg = r"'glyf' and 'loca' must be transformed \(or not\) together" 402 403 with self.assertRaisesRegex(ValueError, msg): 404 WOFF2FlavorData(transformedTables={"glyf"}) 405 406 with self.assertRaisesRegex(ValueError, msg): 407 WOFF2FlavorData(transformedTables={"loca"}) 408 409 410class WOFF2WriterTest(unittest.TestCase): 411 412 @classmethod 413 def setUpClass(cls): 414 cls.font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False, flavor="woff2") 415 cls.font.importXML(OTX) 416 cls.tags = sorted(t for t in cls.font.keys() if t != 'GlyphOrder') 417 cls.numTables = len(cls.tags) 418 cls.file = BytesIO(CFF_WOFF2.getvalue()) 419 cls.file.seek(0, 2) 420 cls.length = (cls.file.tell() + 3) & ~3 421 cls.setUpFlavorData() 422 423 @classmethod 424 def setUpFlavorData(cls): 425 assert os.path.exists(METADATA) 426 with open(METADATA, 'rb') as f: 427 cls.xml_metadata = f.read() 428 cls.compressed_metadata = brotli.compress(cls.xml_metadata, mode=brotli.MODE_TEXT) 429 cls.privData = bytes(bytearray(random.sample(range(0, 256), 20))) 430 431 def setUp(self): 432 self.file.seek(0) 433 self.writer = WOFF2Writer(BytesIO(), self.numTables, self.font.sfntVersion) 434 435 def test_DSIG_dropped(self): 436 self.writer['DSIG'] = b"\0" 437 self.assertEqual(len(self.writer.tables), 0) 438 self.assertEqual(self.writer.numTables, self.numTables-1) 439 440 def test_no_rewrite_table(self): 441 self.writer['ZZZZ'] = b"\0" 442 with self.assertRaisesRegex(ttLib.TTLibError, "cannot rewrite"): 443 self.writer['ZZZZ'] = b"\0" 444 445 def test_num_tables(self): 446 self.writer['ABCD'] = b"\0" 447 with self.assertRaisesRegex(ttLib.TTLibError, "wrong number of tables"): 448 self.writer.close() 449 450 def test_required_tables(self): 451 font = ttLib.TTFont(flavor="woff2") 452 with self.assertRaisesRegex(ttLib.TTLibError, "missing required table"): 453 font.save(BytesIO()) 454 455 def test_head_transform_flag(self): 456 headData = self.font.getTableData('head') 457 origFlags = byteord(headData[16]) 458 woff2font = ttLib.TTFont(self.file) 459 newHeadData = woff2font.getTableData('head') 460 modifiedFlags = byteord(newHeadData[16]) 461 self.assertNotEqual(origFlags, modifiedFlags) 462 restoredFlags = modifiedFlags & ~0x08 # turn off bit 11 463 self.assertEqual(origFlags, restoredFlags) 464 465 def test_tables_sorted_alphabetically(self): 466 expected = sorted([t for t in self.tags if t != 'DSIG']) 467 woff2font = ttLib.TTFont(self.file) 468 self.assertEqual(expected, list(woff2font.reader.keys())) 469 470 def test_checksums(self): 471 normFile = BytesIO(normalise_font(self.font, padding=4)) 472 normFile.seek(0) 473 normFont = ttLib.TTFont(normFile, checkChecksums=2) 474 w2font = ttLib.TTFont(self.file) 475 # force reconstructing glyf table using 4-byte padding 476 w2font.reader.padding = 4 477 for tag in [t for t in self.tags if t != 'DSIG']: 478 w2data = w2font.reader[tag] 479 normData = normFont.reader[tag] 480 if tag == "head": 481 w2data = w2data[:8] + b'\0\0\0\0' + w2data[12:] 482 normData = normData[:8] + b'\0\0\0\0' + normData[12:] 483 w2CheckSum = ttLib.sfnt.calcChecksum(w2data) 484 normCheckSum = ttLib.sfnt.calcChecksum(normData) 485 self.assertEqual(w2CheckSum, normCheckSum) 486 normCheckSumAdjustment = normFont['head'].checkSumAdjustment 487 self.assertEqual(normCheckSumAdjustment, w2font['head'].checkSumAdjustment) 488 489 def test_calcSFNTChecksumsLengthsAndOffsets(self): 490 normFont = ttLib.TTFont(BytesIO(normalise_font(self.font, padding=4))) 491 for tag in self.tags: 492 self.writer[tag] = self.font.getTableData(tag) 493 self.writer._normaliseGlyfAndLoca(padding=4) 494 self.writer._setHeadTransformFlag() 495 self.writer.tables = OrderedDict(sorted(self.writer.tables.items())) 496 self.writer._calcSFNTChecksumsLengthsAndOffsets() 497 for tag, entry in normFont.reader.tables.items(): 498 self.assertEqual(entry.offset, self.writer.tables[tag].origOffset) 499 self.assertEqual(entry.length, self.writer.tables[tag].origLength) 500 self.assertEqual(entry.checkSum, self.writer.tables[tag].checkSum) 501 502 def test_bad_sfntVersion(self): 503 for i in range(self.numTables): 504 self.writer[bytechr(65 + i)*4] = b"\0" 505 self.writer.sfntVersion = 'ZZZZ' 506 with self.assertRaisesRegex(ttLib.TTLibError, "bad sfntVersion"): 507 self.writer.close() 508 509 def test_calcTotalSize_no_flavorData(self): 510 expected = self.length 511 self.writer.file = BytesIO() 512 for tag in self.tags: 513 self.writer[tag] = self.font.getTableData(tag) 514 self.writer.close() 515 self.assertEqual(expected, self.writer.length) 516 self.assertEqual(expected, self.writer.file.tell()) 517 518 def test_calcTotalSize_with_metaData(self): 519 expected = self.length + len(self.compressed_metadata) 520 flavorData = self.writer.flavorData = WOFF2FlavorData() 521 flavorData.metaData = self.xml_metadata 522 self.writer.file = BytesIO() 523 for tag in self.tags: 524 self.writer[tag] = self.font.getTableData(tag) 525 self.writer.close() 526 self.assertEqual(expected, self.writer.length) 527 self.assertEqual(expected, self.writer.file.tell()) 528 529 def test_calcTotalSize_with_privData(self): 530 expected = self.length + len(self.privData) 531 flavorData = self.writer.flavorData = WOFF2FlavorData() 532 flavorData.privData = self.privData 533 self.writer.file = BytesIO() 534 for tag in self.tags: 535 self.writer[tag] = self.font.getTableData(tag) 536 self.writer.close() 537 self.assertEqual(expected, self.writer.length) 538 self.assertEqual(expected, self.writer.file.tell()) 539 540 def test_calcTotalSize_with_metaData_and_privData(self): 541 metaDataLength = (len(self.compressed_metadata) + 3) & ~3 542 expected = self.length + metaDataLength + len(self.privData) 543 flavorData = self.writer.flavorData = WOFF2FlavorData() 544 flavorData.metaData = self.xml_metadata 545 flavorData.privData = self.privData 546 self.writer.file = BytesIO() 547 for tag in self.tags: 548 self.writer[tag] = self.font.getTableData(tag) 549 self.writer.close() 550 self.assertEqual(expected, self.writer.length) 551 self.assertEqual(expected, self.writer.file.tell()) 552 553 def test_getVersion(self): 554 # no version 555 self.assertEqual((0, 0), self.writer._getVersion()) 556 # version from head.fontRevision 557 fontRevision = self.font['head'].fontRevision 558 versionTuple = tuple(int(i) for i in str(fontRevision).split(".")) 559 entry = self.writer.tables['head'] = ttLib.newTable('head') 560 entry.data = self.font.getTableData('head') 561 self.assertEqual(versionTuple, self.writer._getVersion()) 562 # version from writer.flavorData 563 flavorData = self.writer.flavorData = WOFF2FlavorData() 564 flavorData.majorVersion, flavorData.minorVersion = (10, 11) 565 self.assertEqual((10, 11), self.writer._getVersion()) 566 567 def test_hmtx_trasform(self): 568 tableTransforms = {"glyf", "loca", "hmtx"} 569 570 writer = WOFF2Writer(BytesIO(), self.numTables, self.font.sfntVersion) 571 writer.flavorData = WOFF2FlavorData(transformedTables=tableTransforms) 572 573 for tag in self.tags: 574 writer[tag] = self.font.getTableData(tag) 575 writer.close() 576 577 # enabling hmtx transform has no effect when font has no glyf table 578 self.assertEqual(writer.file.getvalue(), CFF_WOFF2.getvalue()) 579 580 def test_no_transforms(self): 581 writer = WOFF2Writer(BytesIO(), self.numTables, self.font.sfntVersion) 582 writer.flavorData = WOFF2FlavorData(transformedTables=()) 583 584 for tag in self.tags: 585 writer[tag] = self.font.getTableData(tag) 586 writer.close() 587 588 # transforms settings have no effect when font is CFF-flavored, since 589 # all the current transforms only apply to TrueType-flavored fonts. 590 self.assertEqual(writer.file.getvalue(), CFF_WOFF2.getvalue()) 591 592class WOFF2WriterTTFTest(WOFF2WriterTest): 593 594 @classmethod 595 def setUpClass(cls): 596 cls.font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False, flavor="woff2") 597 cls.font.importXML(TTX) 598 cls.tags = sorted(t for t in cls.font.keys() if t != 'GlyphOrder') 599 cls.numTables = len(cls.tags) 600 cls.file = BytesIO(TT_WOFF2.getvalue()) 601 cls.file.seek(0, 2) 602 cls.length = (cls.file.tell() + 3) & ~3 603 cls.setUpFlavorData() 604 605 def test_normaliseGlyfAndLoca(self): 606 normTables = {} 607 for tag in ('head', 'loca', 'glyf'): 608 normTables[tag] = normalise_table(self.font, tag, padding=4) 609 for tag in self.tags: 610 tableData = self.font.getTableData(tag) 611 self.writer[tag] = tableData 612 if tag in normTables: 613 self.assertNotEqual(tableData, normTables[tag]) 614 self.writer._normaliseGlyfAndLoca(padding=4) 615 self.writer._setHeadTransformFlag() 616 for tag in normTables: 617 self.assertEqual(self.writer.tables[tag].data, normTables[tag]) 618 619 def test_hmtx_trasform(self): 620 tableTransforms = {"glyf", "loca", "hmtx"} 621 622 writer = WOFF2Writer(BytesIO(), self.numTables, self.font.sfntVersion) 623 writer.flavorData = WOFF2FlavorData(transformedTables=tableTransforms) 624 625 for tag in self.tags: 626 writer[tag] = self.font.getTableData(tag) 627 writer.close() 628 629 length = len(writer.file.getvalue()) 630 631 # enabling optional hmtx transform shaves off a few bytes 632 self.assertLess(length, len(TT_WOFF2.getvalue())) 633 634 def test_no_transforms(self): 635 writer = WOFF2Writer(BytesIO(), self.numTables, self.font.sfntVersion) 636 writer.flavorData = WOFF2FlavorData(transformedTables=()) 637 638 for tag in self.tags: 639 writer[tag] = self.font.getTableData(tag) 640 writer.close() 641 642 self.assertNotEqual(writer.file.getvalue(), TT_WOFF2.getvalue()) 643 644 writer.file.seek(0) 645 reader = WOFF2Reader(writer.file) 646 self.assertEqual(len(reader.flavorData.transformedTables), 0) 647 648 649class WOFF2LocaTableTest(unittest.TestCase): 650 651 def setUp(self): 652 self.font = font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False) 653 font['head'] = ttLib.newTable('head') 654 font['loca'] = WOFF2LocaTable() 655 font['glyf'] = WOFF2GlyfTable() 656 657 def test_compile_short_loca(self): 658 locaTable = self.font['loca'] 659 locaTable.set(list(range(0, 0x20000, 2))) 660 self.font['glyf'].indexFormat = 0 661 locaData = locaTable.compile(self.font) 662 self.assertEqual(len(locaData), 0x20000) 663 664 def test_compile_short_loca_overflow(self): 665 locaTable = self.font['loca'] 666 locaTable.set(list(range(0x20000 + 1))) 667 self.font['glyf'].indexFormat = 0 668 with self.assertRaisesRegex( 669 ttLib.TTLibError, "indexFormat is 0 but local offsets > 0x20000"): 670 locaTable.compile(self.font) 671 672 def test_compile_short_loca_not_multiples_of_2(self): 673 locaTable = self.font['loca'] 674 locaTable.set([1, 3, 5, 7]) 675 self.font['glyf'].indexFormat = 0 676 with self.assertRaisesRegex(ttLib.TTLibError, "offsets not multiples of 2"): 677 locaTable.compile(self.font) 678 679 def test_compile_long_loca(self): 680 locaTable = self.font['loca'] 681 locaTable.set(list(range(0x20001))) 682 self.font['glyf'].indexFormat = 1 683 locaData = locaTable.compile(self.font) 684 self.assertEqual(len(locaData), 0x20001 * 4) 685 686 def test_compile_set_indexToLocFormat_0(self): 687 locaTable = self.font['loca'] 688 # offsets are all multiples of 2 and max length is < 0x10000 689 locaTable.set(list(range(0, 0x20000, 2))) 690 locaTable.compile(self.font) 691 newIndexFormat = self.font['head'].indexToLocFormat 692 self.assertEqual(0, newIndexFormat) 693 694 def test_compile_set_indexToLocFormat_1(self): 695 locaTable = self.font['loca'] 696 # offsets are not multiples of 2 697 locaTable.set(list(range(10))) 698 locaTable.compile(self.font) 699 newIndexFormat = self.font['head'].indexToLocFormat 700 self.assertEqual(1, newIndexFormat) 701 # max length is >= 0x10000 702 locaTable.set(list(range(0, 0x20000 + 1, 2))) 703 locaTable.compile(self.font) 704 newIndexFormat = self.font['head'].indexToLocFormat 705 self.assertEqual(1, newIndexFormat) 706 707 708class WOFF2GlyfTableTest(unittest.TestCase): 709 710 @classmethod 711 def setUpClass(cls): 712 font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False) 713 font.importXML(TTX) 714 cls.tables = {} 715 cls.transformedTags = ('maxp', 'head', 'loca', 'glyf') 716 for tag in reversed(cls.transformedTags): # compile in inverse order 717 cls.tables[tag] = font.getTableData(tag) 718 infile = BytesIO(TT_WOFF2.getvalue()) 719 reader = WOFF2Reader(infile) 720 cls.transformedGlyfData = reader.tables['glyf'].loadData( 721 reader.transformBuffer) 722 cls.glyphOrder = ['.notdef'] + ["glyph%.5d" % i for i in range(1, font['maxp'].numGlyphs)] 723 724 def setUp(self): 725 self.font = font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False) 726 font.setGlyphOrder(self.glyphOrder) 727 font['head'] = ttLib.newTable('head') 728 font['maxp'] = ttLib.newTable('maxp') 729 font['loca'] = WOFF2LocaTable() 730 font['glyf'] = WOFF2GlyfTable() 731 for tag in self.transformedTags: 732 font[tag].decompile(self.tables[tag], font) 733 734 def test_reconstruct_glyf_padded_4(self): 735 glyfTable = WOFF2GlyfTable() 736 glyfTable.reconstruct(self.transformedGlyfData, self.font) 737 glyfTable.padding = 4 738 data = glyfTable.compile(self.font) 739 normGlyfData = normalise_table(self.font, 'glyf', glyfTable.padding) 740 self.assertEqual(normGlyfData, data) 741 742 def test_reconstruct_glyf_padded_2(self): 743 glyfTable = WOFF2GlyfTable() 744 glyfTable.reconstruct(self.transformedGlyfData, self.font) 745 glyfTable.padding = 2 746 data = glyfTable.compile(self.font) 747 normGlyfData = normalise_table(self.font, 'glyf', glyfTable.padding) 748 self.assertEqual(normGlyfData, data) 749 750 def test_reconstruct_glyf_unpadded(self): 751 glyfTable = WOFF2GlyfTable() 752 glyfTable.reconstruct(self.transformedGlyfData, self.font) 753 data = glyfTable.compile(self.font) 754 self.assertEqual(self.tables['glyf'], data) 755 756 def test_reconstruct_glyf_incorrect_glyphOrder(self): 757 glyfTable = WOFF2GlyfTable() 758 badGlyphOrder = self.font.getGlyphOrder()[:-1] 759 self.font.setGlyphOrder(badGlyphOrder) 760 with self.assertRaisesRegex(ttLib.TTLibError, "incorrect glyphOrder"): 761 glyfTable.reconstruct(self.transformedGlyfData, self.font) 762 763 def test_reconstruct_glyf_missing_glyphOrder(self): 764 glyfTable = WOFF2GlyfTable() 765 del self.font.glyphOrder 766 numGlyphs = self.font['maxp'].numGlyphs 767 del self.font['maxp'] 768 glyfTable.reconstruct(self.transformedGlyfData, self.font) 769 expected = [".notdef"] 770 expected.extend(["glyph%.5d" % i for i in range(1, numGlyphs)]) 771 self.assertEqual(expected, glyfTable.glyphOrder) 772 773 def test_reconstruct_loca_padded_4(self): 774 locaTable = self.font['loca'] = WOFF2LocaTable() 775 glyfTable = self.font['glyf'] = WOFF2GlyfTable() 776 glyfTable.reconstruct(self.transformedGlyfData, self.font) 777 glyfTable.padding = 4 778 glyfTable.compile(self.font) 779 data = locaTable.compile(self.font) 780 normLocaData = normalise_table(self.font, 'loca', glyfTable.padding) 781 self.assertEqual(normLocaData, data) 782 783 def test_reconstruct_loca_padded_2(self): 784 locaTable = self.font['loca'] = WOFF2LocaTable() 785 glyfTable = self.font['glyf'] = WOFF2GlyfTable() 786 glyfTable.reconstruct(self.transformedGlyfData, self.font) 787 glyfTable.padding = 2 788 glyfTable.compile(self.font) 789 data = locaTable.compile(self.font) 790 normLocaData = normalise_table(self.font, 'loca', glyfTable.padding) 791 self.assertEqual(normLocaData, data) 792 793 def test_reconstruct_loca_unpadded(self): 794 locaTable = self.font['loca'] = WOFF2LocaTable() 795 glyfTable = self.font['glyf'] = WOFF2GlyfTable() 796 glyfTable.reconstruct(self.transformedGlyfData, self.font) 797 glyfTable.compile(self.font) 798 data = locaTable.compile(self.font) 799 self.assertEqual(self.tables['loca'], data) 800 801 def test_reconstruct_glyf_header_not_enough_data(self): 802 with self.assertRaisesRegex(ttLib.TTLibError, "not enough 'glyf' data"): 803 WOFF2GlyfTable().reconstruct(b"", self.font) 804 805 def test_reconstruct_glyf_table_incorrect_size(self): 806 msg = "incorrect size of transformed 'glyf'" 807 with self.assertRaisesRegex(ttLib.TTLibError, msg): 808 WOFF2GlyfTable().reconstruct(self.transformedGlyfData + b"\x00", self.font) 809 with self.assertRaisesRegex(ttLib.TTLibError, msg): 810 WOFF2GlyfTable().reconstruct(self.transformedGlyfData[:-1], self.font) 811 812 def test_transform_glyf(self): 813 glyfTable = self.font['glyf'] 814 data = glyfTable.transform(self.font) 815 self.assertEqual(self.transformedGlyfData, data) 816 817 def test_roundtrip_glyf_reconstruct_and_transform(self): 818 glyfTable = WOFF2GlyfTable() 819 glyfTable.reconstruct(self.transformedGlyfData, self.font) 820 data = glyfTable.transform(self.font) 821 self.assertEqual(self.transformedGlyfData, data) 822 823 def test_roundtrip_glyf_transform_and_reconstruct(self): 824 glyfTable = self.font['glyf'] 825 transformedData = glyfTable.transform(self.font) 826 newGlyfTable = WOFF2GlyfTable() 827 newGlyfTable.reconstruct(transformedData, self.font) 828 newGlyfTable.padding = 4 829 reconstructedData = newGlyfTable.compile(self.font) 830 normGlyfData = normalise_table(self.font, 'glyf', newGlyfTable.padding) 831 self.assertEqual(normGlyfData, reconstructedData) 832 833 834@pytest.fixture(scope="module") 835def fontfile(): 836 837 class Glyph(object): 838 def __init__(self, empty=False, **kwargs): 839 if not empty: 840 self.draw = partial(self.drawRect, **kwargs) 841 else: 842 self.draw = lambda pen: None 843 844 @staticmethod 845 def drawRect(pen, xMin, xMax): 846 pen.moveTo((xMin, 0)) 847 pen.lineTo((xMin, 1000)) 848 pen.lineTo((xMax, 1000)) 849 pen.lineTo((xMax, 0)) 850 pen.closePath() 851 852 class CompositeGlyph(object): 853 def __init__(self, components): 854 self.components = components 855 856 def draw(self, pen): 857 for baseGlyph, (offsetX, offsetY) in self.components: 858 pen.addComponent(baseGlyph, (1, 0, 0, 1, offsetX, offsetY)) 859 860 fb = fontBuilder.FontBuilder(unitsPerEm=1000, isTTF=True) 861 fb.setupGlyphOrder( 862 [".notdef", "space", "A", "acutecomb", "Aacute", "zero", "one", "two"] 863 ) 864 fb.setupCharacterMap( 865 { 866 0x20: "space", 867 0x41: "A", 868 0x0301: "acutecomb", 869 0xC1: "Aacute", 870 0x30: "zero", 871 0x31: "one", 872 0x32: "two", 873 } 874 ) 875 fb.setupHorizontalMetrics( 876 { 877 ".notdef": (500, 50), 878 "space": (600, 0), 879 "A": (550, 40), 880 "acutecomb": (0, -40), 881 "Aacute": (550, 40), 882 "zero": (500, 30), 883 "one": (500, 50), 884 "two": (500, 40), 885 } 886 ) 887 fb.setupHorizontalHeader(ascent=1000, descent=-200) 888 889 srcGlyphs = { 890 ".notdef": Glyph(xMin=50, xMax=450), 891 "space": Glyph(empty=True), 892 "A": Glyph(xMin=40, xMax=510), 893 "acutecomb": Glyph(xMin=-40, xMax=60), 894 "Aacute": CompositeGlyph([("A", (0, 0)), ("acutecomb", (200, 0))]), 895 "zero": Glyph(xMin=30, xMax=470), 896 "one": Glyph(xMin=50, xMax=450), 897 "two": Glyph(xMin=40, xMax=460), 898 } 899 pen = TTGlyphPen(srcGlyphs) 900 glyphSet = {} 901 for glyphName, glyph in srcGlyphs.items(): 902 glyph.draw(pen) 903 glyphSet[glyphName] = pen.glyph() 904 fb.setupGlyf(glyphSet) 905 906 fb.setupNameTable( 907 { 908 "familyName": "TestWOFF2", 909 "styleName": "Regular", 910 "uniqueFontIdentifier": "TestWOFF2 Regular; Version 1.000; ABCD", 911 "fullName": "TestWOFF2 Regular", 912 "version": "Version 1.000", 913 "psName": "TestWOFF2-Regular", 914 } 915 ) 916 fb.setupOS2() 917 fb.setupPost() 918 919 buf = BytesIO() 920 fb.save(buf) 921 buf.seek(0) 922 923 assert fb.font["maxp"].numGlyphs == 8 924 assert fb.font["hhea"].numberOfHMetrics == 6 925 for glyphName in fb.font.getGlyphOrder(): 926 xMin = getattr(fb.font["glyf"][glyphName], "xMin", 0) 927 assert xMin == fb.font["hmtx"][glyphName][1] 928 929 return buf 930 931 932@pytest.fixture 933def ttFont(fontfile): 934 return ttLib.TTFont(fontfile, recalcBBoxes=False, recalcTimestamp=False) 935 936 937class WOFF2HmtxTableTest(object): 938 def test_transform_no_sidebearings(self, ttFont): 939 hmtxTable = WOFF2HmtxTable() 940 hmtxTable.metrics = ttFont["hmtx"].metrics 941 942 data = hmtxTable.transform(ttFont) 943 944 assert data == ( 945 b"\x03" # 00000011 | bits 0 and 1 are set (no sidebearings arrays) 946 947 # advanceWidthArray 948 b'\x01\xf4' # .notdef: 500 949 b'\x02X' # space: 600 950 b'\x02&' # A: 550 951 b'\x00\x00' # acutecomb: 0 952 b'\x02&' # Aacute: 550 953 b'\x01\xf4' # zero: 500 954 ) 955 956 def test_transform_proportional_sidebearings(self, ttFont): 957 hmtxTable = WOFF2HmtxTable() 958 metrics = ttFont["hmtx"].metrics 959 # force one of the proportional glyphs to have its left sidebearing be 960 # different from its xMin (40) 961 metrics["A"] = (550, 39) 962 hmtxTable.metrics = metrics 963 964 assert ttFont["glyf"]["A"].xMin != metrics["A"][1] 965 966 data = hmtxTable.transform(ttFont) 967 968 assert data == ( 969 b"\x02" # 00000010 | bits 0 unset: explicit proportional sidebearings 970 971 # advanceWidthArray 972 b'\x01\xf4' # .notdef: 500 973 b'\x02X' # space: 600 974 b'\x02&' # A: 550 975 b'\x00\x00' # acutecomb: 0 976 b'\x02&' # Aacute: 550 977 b'\x01\xf4' # zero: 500 978 979 # lsbArray 980 b'\x002' # .notdef: 50 981 b'\x00\x00' # space: 0 982 b"\x00'" # A: 39 (xMin: 40) 983 b'\xff\xd8' # acutecomb: -40 984 b'\x00(' # Aacute: 40 985 b'\x00\x1e' # zero: 30 986 ) 987 988 def test_transform_monospaced_sidebearings(self, ttFont): 989 hmtxTable = WOFF2HmtxTable() 990 metrics = ttFont["hmtx"].metrics 991 hmtxTable.metrics = metrics 992 993 # force one of the monospaced glyphs at the end of hmtx table to have 994 # its xMin different from its left sidebearing (50) 995 ttFont["glyf"]["one"].xMin = metrics["one"][1] + 1 996 997 data = hmtxTable.transform(ttFont) 998 999 assert data == ( 1000 b"\x01" # 00000001 | bits 1 unset: explicit monospaced sidebearings 1001 1002 # advanceWidthArray 1003 b'\x01\xf4' # .notdef: 500 1004 b'\x02X' # space: 600 1005 b'\x02&' # A: 550 1006 b'\x00\x00' # acutecomb: 0 1007 b'\x02&' # Aacute: 550 1008 b'\x01\xf4' # zero: 500 1009 1010 # leftSideBearingArray 1011 b'\x002' # one: 50 (xMin: 51) 1012 b'\x00(' # two: 40 1013 ) 1014 1015 def test_transform_not_applicable(self, ttFont): 1016 hmtxTable = WOFF2HmtxTable() 1017 metrics = ttFont["hmtx"].metrics 1018 # force both a proportional and monospaced glyph to have sidebearings 1019 # different from the respective xMin coordinates 1020 metrics["A"] = (550, 39) 1021 metrics["one"] = (500, 51) 1022 hmtxTable.metrics = metrics 1023 1024 # 'None' signals to fall back using untransformed hmtx table data 1025 assert hmtxTable.transform(ttFont) is None 1026 1027 def test_reconstruct_no_sidebearings(self, ttFont): 1028 hmtxTable = WOFF2HmtxTable() 1029 1030 data = ( 1031 b"\x03" # 00000011 | bits 0 and 1 are set (no sidebearings arrays) 1032 1033 # advanceWidthArray 1034 b'\x01\xf4' # .notdef: 500 1035 b'\x02X' # space: 600 1036 b'\x02&' # A: 550 1037 b'\x00\x00' # acutecomb: 0 1038 b'\x02&' # Aacute: 550 1039 b'\x01\xf4' # zero: 500 1040 ) 1041 1042 hmtxTable.reconstruct(data, ttFont) 1043 1044 assert hmtxTable.metrics == { 1045 ".notdef": (500, 50), 1046 "space": (600, 0), 1047 "A": (550, 40), 1048 "acutecomb": (0, -40), 1049 "Aacute": (550, 40), 1050 "zero": (500, 30), 1051 "one": (500, 50), 1052 "two": (500, 40), 1053 } 1054 1055 def test_reconstruct_proportional_sidebearings(self, ttFont): 1056 hmtxTable = WOFF2HmtxTable() 1057 1058 data = ( 1059 b"\x02" # 00000010 | bits 0 unset: explicit proportional sidebearings 1060 1061 # advanceWidthArray 1062 b'\x01\xf4' # .notdef: 500 1063 b'\x02X' # space: 600 1064 b'\x02&' # A: 550 1065 b'\x00\x00' # acutecomb: 0 1066 b'\x02&' # Aacute: 550 1067 b'\x01\xf4' # zero: 500 1068 1069 # lsbArray 1070 b'\x002' # .notdef: 50 1071 b'\x00\x00' # space: 0 1072 b"\x00'" # A: 39 (xMin: 40) 1073 b'\xff\xd8' # acutecomb: -40 1074 b'\x00(' # Aacute: 40 1075 b'\x00\x1e' # zero: 30 1076 ) 1077 1078 hmtxTable.reconstruct(data, ttFont) 1079 1080 assert hmtxTable.metrics == { 1081 ".notdef": (500, 50), 1082 "space": (600, 0), 1083 "A": (550, 39), 1084 "acutecomb": (0, -40), 1085 "Aacute": (550, 40), 1086 "zero": (500, 30), 1087 "one": (500, 50), 1088 "two": (500, 40), 1089 } 1090 1091 assert ttFont["glyf"]["A"].xMin == 40 1092 1093 def test_reconstruct_monospaced_sidebearings(self, ttFont): 1094 hmtxTable = WOFF2HmtxTable() 1095 1096 data = ( 1097 b"\x01" # 00000001 | bits 1 unset: explicit monospaced sidebearings 1098 1099 # advanceWidthArray 1100 b'\x01\xf4' # .notdef: 500 1101 b'\x02X' # space: 600 1102 b'\x02&' # A: 550 1103 b'\x00\x00' # acutecomb: 0 1104 b'\x02&' # Aacute: 550 1105 b'\x01\xf4' # zero: 500 1106 1107 # leftSideBearingArray 1108 b'\x003' # one: 51 (xMin: 50) 1109 b'\x00(' # two: 40 1110 ) 1111 1112 hmtxTable.reconstruct(data, ttFont) 1113 1114 assert hmtxTable.metrics == { 1115 ".notdef": (500, 50), 1116 "space": (600, 0), 1117 "A": (550, 40), 1118 "acutecomb": (0, -40), 1119 "Aacute": (550, 40), 1120 "zero": (500, 30), 1121 "one": (500, 51), 1122 "two": (500, 40), 1123 } 1124 1125 assert ttFont["glyf"]["one"].xMin == 50 1126 1127 def test_reconstruct_flags_reserved_bits(self): 1128 hmtxTable = WOFF2HmtxTable() 1129 1130 with pytest.raises( 1131 ttLib.TTLibError, match="Bits 2-7 of 'hmtx' flags are reserved" 1132 ): 1133 hmtxTable.reconstruct(b"\xFF", ttFont=None) 1134 1135 def test_reconstruct_flags_required_bits(self): 1136 hmtxTable = WOFF2HmtxTable() 1137 1138 with pytest.raises(ttLib.TTLibError, match="either bits 0 or 1 .* must set"): 1139 hmtxTable.reconstruct(b"\x00", ttFont=None) 1140 1141 def test_reconstruct_too_much_data(self, ttFont): 1142 ttFont["hhea"].numberOfHMetrics = 2 1143 data = b'\x03\x01\xf4\x02X\x02&' 1144 hmtxTable = WOFF2HmtxTable() 1145 1146 with pytest.raises(ttLib.TTLibError, match="too much 'hmtx' table data"): 1147 hmtxTable.reconstruct(data, ttFont) 1148 1149 1150class WOFF2RoundtripTest(object): 1151 @staticmethod 1152 def roundtrip(infile): 1153 infile.seek(0) 1154 ttFont = ttLib.TTFont(infile, recalcBBoxes=False, recalcTimestamp=False) 1155 outfile = BytesIO() 1156 ttFont.save(outfile) 1157 return outfile, ttFont 1158 1159 def test_roundtrip_default_transforms(self, ttFont): 1160 ttFont.flavor = "woff2" 1161 # ttFont.flavorData = None 1162 tmp = BytesIO() 1163 ttFont.save(tmp) 1164 1165 tmp2, ttFont2 = self.roundtrip(tmp) 1166 1167 assert tmp.getvalue() == tmp2.getvalue() 1168 assert ttFont2.reader.flavorData.transformedTables == {"glyf", "loca"} 1169 1170 def test_roundtrip_no_transforms(self, ttFont): 1171 ttFont.flavor = "woff2" 1172 ttFont.flavorData = WOFF2FlavorData(transformedTables=[]) 1173 tmp = BytesIO() 1174 ttFont.save(tmp) 1175 1176 tmp2, ttFont2 = self.roundtrip(tmp) 1177 1178 assert tmp.getvalue() == tmp2.getvalue() 1179 assert not ttFont2.reader.flavorData.transformedTables 1180 1181 def test_roundtrip_all_transforms(self, ttFont): 1182 ttFont.flavor = "woff2" 1183 ttFont.flavorData = WOFF2FlavorData(transformedTables=["glyf", "loca", "hmtx"]) 1184 tmp = BytesIO() 1185 ttFont.save(tmp) 1186 1187 tmp2, ttFont2 = self.roundtrip(tmp) 1188 1189 assert tmp.getvalue() == tmp2.getvalue() 1190 assert ttFont2.reader.flavorData.transformedTables == {"glyf", "loca", "hmtx"} 1191 1192 def test_roundtrip_only_hmtx_no_glyf_transform(self, ttFont): 1193 ttFont.flavor = "woff2" 1194 ttFont.flavorData = WOFF2FlavorData(transformedTables=["hmtx"]) 1195 tmp = BytesIO() 1196 ttFont.save(tmp) 1197 1198 tmp2, ttFont2 = self.roundtrip(tmp) 1199 1200 assert tmp.getvalue() == tmp2.getvalue() 1201 assert ttFont2.reader.flavorData.transformedTables == {"hmtx"} 1202 1203 1204class MainTest(object): 1205 1206 @staticmethod 1207 def make_ttf(tmpdir): 1208 ttFont = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False) 1209 ttFont.importXML(TTX) 1210 filename = str(tmpdir / "TestTTF-Regular.ttf") 1211 ttFont.save(filename) 1212 return filename 1213 1214 def test_compress_ttf(self, tmpdir): 1215 input_file = self.make_ttf(tmpdir) 1216 1217 assert woff2.main(["compress", input_file]) is None 1218 1219 assert (tmpdir / "TestTTF-Regular.woff2").check(file=True) 1220 1221 def test_compress_ttf_no_glyf_transform(self, tmpdir): 1222 input_file = self.make_ttf(tmpdir) 1223 1224 assert woff2.main(["compress", "--no-glyf-transform", input_file]) is None 1225 1226 assert (tmpdir / "TestTTF-Regular.woff2").check(file=True) 1227 1228 def test_compress_ttf_hmtx_transform(self, tmpdir): 1229 input_file = self.make_ttf(tmpdir) 1230 1231 assert woff2.main(["compress", "--hmtx-transform", input_file]) is None 1232 1233 assert (tmpdir / "TestTTF-Regular.woff2").check(file=True) 1234 1235 def test_compress_ttf_no_glyf_transform_hmtx_transform(self, tmpdir): 1236 input_file = self.make_ttf(tmpdir) 1237 1238 assert woff2.main( 1239 ["compress", "--no-glyf-transform", "--hmtx-transform", input_file] 1240 ) is None 1241 1242 assert (tmpdir / "TestTTF-Regular.woff2").check(file=True) 1243 1244 def test_compress_output_file(self, tmpdir): 1245 input_file = self.make_ttf(tmpdir) 1246 output_file = tmpdir / "TestTTF.woff2" 1247 1248 assert woff2.main( 1249 ["compress", "-o", str(output_file), str(input_file)] 1250 ) is None 1251 1252 assert output_file.check(file=True) 1253 1254 def test_compress_otf(self, tmpdir): 1255 ttFont = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False) 1256 ttFont.importXML(OTX) 1257 input_file = str(tmpdir / "TestOTF-Regular.otf") 1258 ttFont.save(input_file) 1259 1260 assert woff2.main(["compress", input_file]) is None 1261 1262 assert (tmpdir / "TestOTF-Regular.woff2").check(file=True) 1263 1264 def test_recompress_woff2_keeps_flavorData(self, tmpdir): 1265 woff2_font = ttLib.TTFont(BytesIO(TT_WOFF2.getvalue())) 1266 woff2_font.flavorData.privData = b"FOOBAR" 1267 woff2_file = tmpdir / "TestTTF-Regular.woff2" 1268 woff2_font.save(str(woff2_file)) 1269 1270 assert woff2_font.flavorData.transformedTables == {"glyf", "loca"} 1271 1272 woff2.main(["compress", "--hmtx-transform", str(woff2_file)]) 1273 1274 output_file = tmpdir / "TestTTF-Regular#1.woff2" 1275 assert output_file.check(file=True) 1276 1277 new_woff2_font = ttLib.TTFont(str(output_file)) 1278 1279 assert new_woff2_font.flavorData.transformedTables == {"glyf", "loca", "hmtx"} 1280 assert new_woff2_font.flavorData.privData == b"FOOBAR" 1281 1282 def test_decompress_ttf(self, tmpdir): 1283 input_file = tmpdir / "TestTTF-Regular.woff2" 1284 input_file.write_binary(TT_WOFF2.getvalue()) 1285 1286 assert woff2.main(["decompress", str(input_file)]) is None 1287 1288 assert (tmpdir / "TestTTF-Regular.ttf").check(file=True) 1289 1290 def test_decompress_otf(self, tmpdir): 1291 input_file = tmpdir / "TestTTF-Regular.woff2" 1292 input_file.write_binary(CFF_WOFF2.getvalue()) 1293 1294 assert woff2.main(["decompress", str(input_file)]) is None 1295 1296 assert (tmpdir / "TestTTF-Regular.otf").check(file=True) 1297 1298 def test_decompress_output_file(self, tmpdir): 1299 input_file = tmpdir / "TestTTF-Regular.woff2" 1300 input_file.write_binary(TT_WOFF2.getvalue()) 1301 output_file = tmpdir / "TestTTF.ttf" 1302 1303 assert woff2.main( 1304 ["decompress", "-o", str(output_file), str(input_file)] 1305 ) is None 1306 1307 assert output_file.check(file=True) 1308 1309 def test_no_subcommand_show_help(self, capsys): 1310 with pytest.raises(SystemExit): 1311 woff2.main(["--help"]) 1312 1313 captured = capsys.readouterr() 1314 assert "usage: fonttools ttLib.woff2" in captured.out 1315 1316 1317class Base128Test(unittest.TestCase): 1318 1319 def test_unpackBase128(self): 1320 self.assertEqual(unpackBase128(b'\x3f\x00\x00'), (63, b"\x00\x00")) 1321 self.assertEqual(unpackBase128(b'\x8f\xff\xff\xff\x7f')[0], 4294967295) 1322 1323 self.assertRaisesRegex( 1324 ttLib.TTLibError, 1325 "UIntBase128 value must not start with leading zeros", 1326 unpackBase128, b'\x80\x80\x3f') 1327 1328 self.assertRaisesRegex( 1329 ttLib.TTLibError, 1330 "UIntBase128-encoded sequence is longer than 5 bytes", 1331 unpackBase128, b'\x8f\xff\xff\xff\xff\x7f') 1332 1333 self.assertRaisesRegex( 1334 ttLib.TTLibError, 1335 r"UIntBase128 value exceeds 2\*\*32-1", 1336 unpackBase128, b'\x90\x80\x80\x80\x00') 1337 1338 self.assertRaisesRegex( 1339 ttLib.TTLibError, 1340 "not enough data to unpack UIntBase128", 1341 unpackBase128, b'') 1342 1343 def test_base128Size(self): 1344 self.assertEqual(base128Size(0), 1) 1345 self.assertEqual(base128Size(24567), 3) 1346 self.assertEqual(base128Size(2**32-1), 5) 1347 1348 def test_packBase128(self): 1349 self.assertEqual(packBase128(63), b"\x3f") 1350 self.assertEqual(packBase128(2**32-1), b'\x8f\xff\xff\xff\x7f') 1351 self.assertRaisesRegex( 1352 ttLib.TTLibError, 1353 r"UIntBase128 format requires 0 <= integer <= 2\*\*32-1", 1354 packBase128, 2**32+1) 1355 self.assertRaisesRegex( 1356 ttLib.TTLibError, 1357 r"UIntBase128 format requires 0 <= integer <= 2\*\*32-1", 1358 packBase128, -1) 1359 1360 1361class UShort255Test(unittest.TestCase): 1362 1363 def test_unpack255UShort(self): 1364 self.assertEqual(unpack255UShort(bytechr(252))[0], 252) 1365 # some numbers (e.g. 506) can have multiple encodings 1366 self.assertEqual( 1367 unpack255UShort(struct.pack(b"BB", 254, 0))[0], 506) 1368 self.assertEqual( 1369 unpack255UShort(struct.pack(b"BB", 255, 253))[0], 506) 1370 self.assertEqual( 1371 unpack255UShort(struct.pack(b"BBB", 253, 1, 250))[0], 506) 1372 1373 self.assertRaisesRegex( 1374 ttLib.TTLibError, 1375 "not enough data to unpack 255UInt16", 1376 unpack255UShort, struct.pack(b"BB", 253, 0)) 1377 1378 self.assertRaisesRegex( 1379 ttLib.TTLibError, 1380 "not enough data to unpack 255UInt16", 1381 unpack255UShort, struct.pack(b"B", 254)) 1382 1383 self.assertRaisesRegex( 1384 ttLib.TTLibError, 1385 "not enough data to unpack 255UInt16", 1386 unpack255UShort, struct.pack(b"B", 255)) 1387 1388 def test_pack255UShort(self): 1389 self.assertEqual(pack255UShort(252), b'\xfc') 1390 self.assertEqual(pack255UShort(505), b'\xff\xfc') 1391 self.assertEqual(pack255UShort(506), b'\xfe\x00') 1392 self.assertEqual(pack255UShort(762), b'\xfd\x02\xfa') 1393 1394 self.assertRaisesRegex( 1395 ttLib.TTLibError, 1396 "255UInt16 format requires 0 <= integer <= 65535", 1397 pack255UShort, -1) 1398 1399 self.assertRaisesRegex( 1400 ttLib.TTLibError, 1401 "255UInt16 format requires 0 <= integer <= 65535", 1402 pack255UShort, 0xFFFF+1) 1403 1404 1405if __name__ == "__main__": 1406 import sys 1407 sys.exit(unittest.main()) 1408