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