| from __future__ import print_function, division, absolute_import, unicode_literals |
| from fontTools.misc.py23 import * |
| from fontTools import ttLib |
| from fontTools.ttLib import woff2 |
| from fontTools.ttLib.woff2 import ( |
| WOFF2Reader, woff2DirectorySize, woff2DirectoryFormat, |
| woff2FlagsSize, woff2UnknownTagSize, woff2Base128MaxSize, WOFF2DirectoryEntry, |
| getKnownTagIndex, packBase128, base128Size, woff2UnknownTagIndex, |
| WOFF2FlavorData, woff2TransformedTableTags, WOFF2GlyfTable, WOFF2LocaTable, |
| WOFF2HmtxTable, WOFF2Writer, unpackBase128, unpack255UShort, pack255UShort) |
| import unittest |
| from fontTools.misc import sstruct |
| from fontTools import fontBuilder |
| from fontTools.pens.ttGlyphPen import TTGlyphPen |
| import struct |
| import os |
| import random |
| import copy |
| from collections import OrderedDict |
| from functools import partial |
| import pytest |
| |
| haveBrotli = False |
| try: |
| import brotli |
| haveBrotli = True |
| except ImportError: |
| pass |
| |
| |
| # Python 3 renamed 'assertRaisesRegexp' to 'assertRaisesRegex', and fires |
| # deprecation warnings if a program uses the old name. |
| if not hasattr(unittest.TestCase, 'assertRaisesRegex'): |
| unittest.TestCase.assertRaisesRegex = unittest.TestCase.assertRaisesRegexp |
| |
| |
| current_dir = os.path.abspath(os.path.dirname(os.path.realpath(__file__))) |
| data_dir = os.path.join(current_dir, 'data') |
| TTX = os.path.join(data_dir, 'TestTTF-Regular.ttx') |
| OTX = os.path.join(data_dir, 'TestOTF-Regular.otx') |
| METADATA = os.path.join(data_dir, 'test_woff2_metadata.xml') |
| |
| TT_WOFF2 = BytesIO() |
| CFF_WOFF2 = BytesIO() |
| |
| |
| def setUpModule(): |
| if not haveBrotli: |
| raise unittest.SkipTest("No module named brotli") |
| assert os.path.exists(TTX) |
| assert os.path.exists(OTX) |
| # import TT-flavoured test font and save it as WOFF2 |
| ttf = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False) |
| ttf.importXML(TTX) |
| ttf.flavor = "woff2" |
| ttf.save(TT_WOFF2, reorderTables=None) |
| # import CFF-flavoured test font and save it as WOFF2 |
| otf = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False) |
| otf.importXML(OTX) |
| otf.flavor = "woff2" |
| otf.save(CFF_WOFF2, reorderTables=None) |
| |
| |
| class WOFF2ReaderTest(unittest.TestCase): |
| |
| @classmethod |
| def setUpClass(cls): |
| cls.file = BytesIO(CFF_WOFF2.getvalue()) |
| cls.font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False) |
| cls.font.importXML(OTX) |
| |
| def setUp(self): |
| self.file.seek(0) |
| |
| def test_bad_signature(self): |
| with self.assertRaisesRegex(ttLib.TTLibError, 'bad signature'): |
| WOFF2Reader(BytesIO(b"wOFF")) |
| |
| def test_not_enough_data_header(self): |
| incomplete_header = self.file.read(woff2DirectorySize - 1) |
| with self.assertRaisesRegex(ttLib.TTLibError, 'not enough data'): |
| WOFF2Reader(BytesIO(incomplete_header)) |
| |
| def test_incorrect_compressed_size(self): |
| data = self.file.read(woff2DirectorySize) |
| header = sstruct.unpack(woff2DirectoryFormat, data) |
| header['totalCompressedSize'] = 0 |
| data = sstruct.pack(woff2DirectoryFormat, header) |
| with self.assertRaises((brotli.error, ttLib.TTLibError)): |
| WOFF2Reader(BytesIO(data + self.file.read())) |
| |
| def test_incorrect_uncompressed_size(self): |
| decompress_backup = brotli.decompress |
| brotli.decompress = lambda data: b"" # return empty byte string |
| with self.assertRaisesRegex(ttLib.TTLibError, 'unexpected size for decompressed'): |
| WOFF2Reader(self.file) |
| brotli.decompress = decompress_backup |
| |
| def test_incorrect_file_size(self): |
| data = self.file.read(woff2DirectorySize) |
| header = sstruct.unpack(woff2DirectoryFormat, data) |
| header['length'] -= 1 |
| data = sstruct.pack(woff2DirectoryFormat, header) |
| with self.assertRaisesRegex( |
| ttLib.TTLibError, "doesn't match the actual file size"): |
| WOFF2Reader(BytesIO(data + self.file.read())) |
| |
| def test_num_tables(self): |
| tags = [t for t in self.font.keys() if t not in ('GlyphOrder', 'DSIG')] |
| data = self.file.read(woff2DirectorySize) |
| header = sstruct.unpack(woff2DirectoryFormat, data) |
| self.assertEqual(header['numTables'], len(tags)) |
| |
| def test_table_tags(self): |
| tags = set([t for t in self.font.keys() if t not in ('GlyphOrder', 'DSIG')]) |
| reader = WOFF2Reader(self.file) |
| self.assertEqual(set(reader.keys()), tags) |
| |
| def test_get_normal_tables(self): |
| woff2Reader = WOFF2Reader(self.file) |
| specialTags = woff2TransformedTableTags + ('head', 'GlyphOrder', 'DSIG') |
| for tag in [t for t in self.font.keys() if t not in specialTags]: |
| origData = self.font.getTableData(tag) |
| decompressedData = woff2Reader[tag] |
| self.assertEqual(origData, decompressedData) |
| |
| def test_reconstruct_unknown(self): |
| reader = WOFF2Reader(self.file) |
| with self.assertRaisesRegex(ttLib.TTLibError, 'transform for table .* unknown'): |
| reader.reconstructTable('head') |
| |
| |
| class WOFF2ReaderTTFTest(WOFF2ReaderTest): |
| """ Tests specific to TT-flavored fonts. """ |
| |
| @classmethod |
| def setUpClass(cls): |
| cls.file = BytesIO(TT_WOFF2.getvalue()) |
| cls.font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False) |
| cls.font.importXML(TTX) |
| |
| def setUp(self): |
| self.file.seek(0) |
| |
| def test_reconstruct_glyf(self): |
| woff2Reader = WOFF2Reader(self.file) |
| reconstructedData = woff2Reader['glyf'] |
| self.assertEqual(self.font.getTableData('glyf'), reconstructedData) |
| |
| def test_reconstruct_loca(self): |
| woff2Reader = WOFF2Reader(self.file) |
| reconstructedData = woff2Reader['loca'] |
| self.font.getTableData("glyf") # 'glyf' needs to be compiled before 'loca' |
| self.assertEqual(self.font.getTableData('loca'), reconstructedData) |
| self.assertTrue(hasattr(woff2Reader.tables['glyf'], 'data')) |
| |
| def test_reconstruct_loca_not_match_orig_size(self): |
| reader = WOFF2Reader(self.file) |
| reader.tables['loca'].origLength -= 1 |
| with self.assertRaisesRegex( |
| ttLib.TTLibError, "'loca' table doesn't match original size"): |
| reader.reconstructTable('loca') |
| |
| |
| def normalise_table(font, tag, padding=4): |
| """ Return normalised table data. Keep 'font' instance unmodified. """ |
| assert tag in ('glyf', 'loca', 'head') |
| assert tag in font |
| if tag == 'head': |
| origHeadFlags = font['head'].flags |
| font['head'].flags |= (1 << 11) |
| tableData = font['head'].compile(font) |
| if font.sfntVersion in ("\x00\x01\x00\x00", "true"): |
| assert {'glyf', 'loca', 'head'}.issubset(font.keys()) |
| origIndexFormat = font['head'].indexToLocFormat |
| if hasattr(font['loca'], 'locations'): |
| origLocations = font['loca'].locations[:] |
| else: |
| origLocations = [] |
| glyfTable = ttLib.newTable('glyf') |
| glyfTable.decompile(font.getTableData('glyf'), font) |
| glyfTable.padding = padding |
| if tag == 'glyf': |
| tableData = glyfTable.compile(font) |
| elif tag == 'loca': |
| glyfTable.compile(font) |
| tableData = font['loca'].compile(font) |
| if tag == 'head': |
| glyfTable.compile(font) |
| font['loca'].compile(font) |
| tableData = font['head'].compile(font) |
| font['head'].indexToLocFormat = origIndexFormat |
| font['loca'].set(origLocations) |
| if tag == 'head': |
| font['head'].flags = origHeadFlags |
| return tableData |
| |
| |
| def normalise_font(font, padding=4): |
| """ Return normalised font data. Keep 'font' instance unmodified. """ |
| # drop DSIG but keep a copy |
| DSIG_copy = copy.deepcopy(font['DSIG']) |
| del font['DSIG'] |
| # ovverride TTFont attributes |
| origFlavor = font.flavor |
| origRecalcBBoxes = font.recalcBBoxes |
| origRecalcTimestamp = font.recalcTimestamp |
| origLazy = font.lazy |
| font.flavor = None |
| font.recalcBBoxes = False |
| font.recalcTimestamp = False |
| font.lazy = True |
| # save font to temporary stream |
| infile = BytesIO() |
| font.save(infile) |
| infile.seek(0) |
| # reorder tables alphabetically |
| outfile = BytesIO() |
| reader = ttLib.sfnt.SFNTReader(infile) |
| writer = ttLib.sfnt.SFNTWriter( |
| outfile, len(reader.tables), reader.sfntVersion, reader.flavor, reader.flavorData) |
| for tag in sorted(reader.keys()): |
| if tag in woff2TransformedTableTags + ('head',): |
| writer[tag] = normalise_table(font, tag, padding) |
| else: |
| writer[tag] = reader[tag] |
| writer.close() |
| # restore font attributes |
| font['DSIG'] = DSIG_copy |
| font.flavor = origFlavor |
| font.recalcBBoxes = origRecalcBBoxes |
| font.recalcTimestamp = origRecalcTimestamp |
| font.lazy = origLazy |
| return outfile.getvalue() |
| |
| |
| class WOFF2DirectoryEntryTest(unittest.TestCase): |
| |
| def setUp(self): |
| self.entry = WOFF2DirectoryEntry() |
| |
| def test_not_enough_data_table_flags(self): |
| with self.assertRaisesRegex(ttLib.TTLibError, "can't read table 'flags'"): |
| self.entry.fromString(b"") |
| |
| def test_not_enough_data_table_tag(self): |
| incompleteData = bytearray([0x3F, 0, 0, 0]) |
| with self.assertRaisesRegex(ttLib.TTLibError, "can't read table 'tag'"): |
| self.entry.fromString(bytes(incompleteData)) |
| |
| def test_loca_zero_transformLength(self): |
| data = bytechr(getKnownTagIndex('loca')) # flags |
| data += packBase128(random.randint(1, 100)) # origLength |
| data += packBase128(1) # non-zero transformLength |
| with self.assertRaisesRegex( |
| ttLib.TTLibError, "transformLength of the 'loca' table must be 0"): |
| self.entry.fromString(data) |
| |
| def test_fromFile(self): |
| unknownTag = Tag('ZZZZ') |
| data = bytechr(getKnownTagIndex(unknownTag)) |
| data += unknownTag.tobytes() |
| data += packBase128(random.randint(1, 100)) |
| expectedPos = len(data) |
| f = BytesIO(data + b'\0'*100) |
| self.entry.fromFile(f) |
| self.assertEqual(f.tell(), expectedPos) |
| |
| def test_transformed_toString(self): |
| self.entry.tag = Tag('glyf') |
| self.entry.flags = getKnownTagIndex(self.entry.tag) |
| self.entry.origLength = random.randint(101, 200) |
| self.entry.length = random.randint(1, 100) |
| expectedSize = (woff2FlagsSize + base128Size(self.entry.origLength) + |
| base128Size(self.entry.length)) |
| data = self.entry.toString() |
| self.assertEqual(len(data), expectedSize) |
| |
| def test_known_toString(self): |
| self.entry.tag = Tag('head') |
| self.entry.flags = getKnownTagIndex(self.entry.tag) |
| self.entry.origLength = 54 |
| expectedSize = (woff2FlagsSize + base128Size(self.entry.origLength)) |
| data = self.entry.toString() |
| self.assertEqual(len(data), expectedSize) |
| |
| def test_unknown_toString(self): |
| self.entry.tag = Tag('ZZZZ') |
| self.entry.flags = woff2UnknownTagIndex |
| self.entry.origLength = random.randint(1, 100) |
| expectedSize = (woff2FlagsSize + woff2UnknownTagSize + |
| base128Size(self.entry.origLength)) |
| data = self.entry.toString() |
| self.assertEqual(len(data), expectedSize) |
| |
| def test_glyf_loca_transform_flags(self): |
| for tag in ("glyf", "loca"): |
| entry = WOFF2DirectoryEntry() |
| entry.tag = Tag(tag) |
| entry.flags = getKnownTagIndex(entry.tag) |
| |
| self.assertEqual(entry.transformVersion, 0) |
| self.assertTrue(entry.transformed) |
| |
| entry.transformed = False |
| |
| self.assertEqual(entry.transformVersion, 3) |
| self.assertEqual(entry.flags & 0b11000000, (3 << 6)) |
| self.assertFalse(entry.transformed) |
| |
| def test_other_transform_flags(self): |
| entry = WOFF2DirectoryEntry() |
| entry.tag = Tag('ZZZZ') |
| entry.flags = woff2UnknownTagIndex |
| |
| self.assertEqual(entry.transformVersion, 0) |
| self.assertFalse(entry.transformed) |
| |
| entry.transformed = True |
| |
| self.assertEqual(entry.transformVersion, 1) |
| self.assertEqual(entry.flags & 0b11000000, (1 << 6)) |
| self.assertTrue(entry.transformed) |
| |
| |
| class DummyReader(WOFF2Reader): |
| |
| def __init__(self, file, checkChecksums=1, fontNumber=-1): |
| self.file = file |
| for attr in ('majorVersion', 'minorVersion', 'metaOffset', 'metaLength', |
| 'metaOrigLength', 'privLength', 'privOffset'): |
| setattr(self, attr, 0) |
| self.tables = {} |
| |
| |
| class WOFF2FlavorDataTest(unittest.TestCase): |
| |
| @classmethod |
| def setUpClass(cls): |
| assert os.path.exists(METADATA) |
| with open(METADATA, 'rb') as f: |
| cls.xml_metadata = f.read() |
| cls.compressed_metadata = brotli.compress(cls.xml_metadata, mode=brotli.MODE_TEXT) |
| # make random byte strings; font data must be 4-byte aligned |
| cls.fontdata = bytes(bytearray(random.sample(range(0, 256), 80))) |
| cls.privData = bytes(bytearray(random.sample(range(0, 256), 20))) |
| |
| def setUp(self): |
| self.file = BytesIO(self.fontdata) |
| self.file.seek(0, 2) |
| |
| def test_get_metaData_no_privData(self): |
| self.file.write(self.compressed_metadata) |
| reader = DummyReader(self.file) |
| reader.metaOffset = len(self.fontdata) |
| reader.metaLength = len(self.compressed_metadata) |
| reader.metaOrigLength = len(self.xml_metadata) |
| flavorData = WOFF2FlavorData(reader) |
| self.assertEqual(self.xml_metadata, flavorData.metaData) |
| |
| def test_get_privData_no_metaData(self): |
| self.file.write(self.privData) |
| reader = DummyReader(self.file) |
| reader.privOffset = len(self.fontdata) |
| reader.privLength = len(self.privData) |
| flavorData = WOFF2FlavorData(reader) |
| self.assertEqual(self.privData, flavorData.privData) |
| |
| def test_get_metaData_and_privData(self): |
| self.file.write(self.compressed_metadata + self.privData) |
| reader = DummyReader(self.file) |
| reader.metaOffset = len(self.fontdata) |
| reader.metaLength = len(self.compressed_metadata) |
| reader.metaOrigLength = len(self.xml_metadata) |
| reader.privOffset = reader.metaOffset + reader.metaLength |
| reader.privLength = len(self.privData) |
| flavorData = WOFF2FlavorData(reader) |
| self.assertEqual(self.xml_metadata, flavorData.metaData) |
| self.assertEqual(self.privData, flavorData.privData) |
| |
| def test_get_major_minorVersion(self): |
| reader = DummyReader(self.file) |
| reader.majorVersion = reader.minorVersion = 1 |
| flavorData = WOFF2FlavorData(reader) |
| self.assertEqual(flavorData.majorVersion, 1) |
| self.assertEqual(flavorData.minorVersion, 1) |
| |
| def test_mutually_exclusive_args(self): |
| msg = "arguments are mutually exclusive" |
| reader = DummyReader(self.file) |
| with self.assertRaisesRegex(TypeError, msg): |
| WOFF2FlavorData(reader, transformedTables={"hmtx"}) |
| with self.assertRaisesRegex(TypeError, msg): |
| WOFF2FlavorData(reader, data=WOFF2FlavorData()) |
| |
| def test_transformedTables_default(self): |
| flavorData = WOFF2FlavorData() |
| self.assertEqual(flavorData.transformedTables, set(woff2TransformedTableTags)) |
| |
| def test_transformedTables_invalid(self): |
| msg = r"'glyf' and 'loca' must be transformed \(or not\) together" |
| |
| with self.assertRaisesRegex(ValueError, msg): |
| WOFF2FlavorData(transformedTables={"glyf"}) |
| |
| with self.assertRaisesRegex(ValueError, msg): |
| WOFF2FlavorData(transformedTables={"loca"}) |
| |
| |
| class WOFF2WriterTest(unittest.TestCase): |
| |
| @classmethod |
| def setUpClass(cls): |
| cls.font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False, flavor="woff2") |
| cls.font.importXML(OTX) |
| cls.tags = sorted(t for t in cls.font.keys() if t != 'GlyphOrder') |
| cls.numTables = len(cls.tags) |
| cls.file = BytesIO(CFF_WOFF2.getvalue()) |
| cls.file.seek(0, 2) |
| cls.length = (cls.file.tell() + 3) & ~3 |
| cls.setUpFlavorData() |
| |
| @classmethod |
| def setUpFlavorData(cls): |
| assert os.path.exists(METADATA) |
| with open(METADATA, 'rb') as f: |
| cls.xml_metadata = f.read() |
| cls.compressed_metadata = brotli.compress(cls.xml_metadata, mode=brotli.MODE_TEXT) |
| cls.privData = bytes(bytearray(random.sample(range(0, 256), 20))) |
| |
| def setUp(self): |
| self.file.seek(0) |
| self.writer = WOFF2Writer(BytesIO(), self.numTables, self.font.sfntVersion) |
| |
| def test_DSIG_dropped(self): |
| self.writer['DSIG'] = b"\0" |
| self.assertEqual(len(self.writer.tables), 0) |
| self.assertEqual(self.writer.numTables, self.numTables-1) |
| |
| def test_no_rewrite_table(self): |
| self.writer['ZZZZ'] = b"\0" |
| with self.assertRaisesRegex(ttLib.TTLibError, "cannot rewrite"): |
| self.writer['ZZZZ'] = b"\0" |
| |
| def test_num_tables(self): |
| self.writer['ABCD'] = b"\0" |
| with self.assertRaisesRegex(ttLib.TTLibError, "wrong number of tables"): |
| self.writer.close() |
| |
| def test_required_tables(self): |
| font = ttLib.TTFont(flavor="woff2") |
| with self.assertRaisesRegex(ttLib.TTLibError, "missing required table"): |
| font.save(BytesIO()) |
| |
| def test_head_transform_flag(self): |
| headData = self.font.getTableData('head') |
| origFlags = byteord(headData[16]) |
| woff2font = ttLib.TTFont(self.file) |
| newHeadData = woff2font.getTableData('head') |
| modifiedFlags = byteord(newHeadData[16]) |
| self.assertNotEqual(origFlags, modifiedFlags) |
| restoredFlags = modifiedFlags & ~0x08 # turn off bit 11 |
| self.assertEqual(origFlags, restoredFlags) |
| |
| def test_tables_sorted_alphabetically(self): |
| expected = sorted([t for t in self.tags if t != 'DSIG']) |
| woff2font = ttLib.TTFont(self.file) |
| self.assertEqual(expected, list(woff2font.reader.keys())) |
| |
| def test_checksums(self): |
| normFile = BytesIO(normalise_font(self.font, padding=4)) |
| normFile.seek(0) |
| normFont = ttLib.TTFont(normFile, checkChecksums=2) |
| w2font = ttLib.TTFont(self.file) |
| # force reconstructing glyf table using 4-byte padding |
| w2font.reader.padding = 4 |
| for tag in [t for t in self.tags if t != 'DSIG']: |
| w2data = w2font.reader[tag] |
| normData = normFont.reader[tag] |
| if tag == "head": |
| w2data = w2data[:8] + b'\0\0\0\0' + w2data[12:] |
| normData = normData[:8] + b'\0\0\0\0' + normData[12:] |
| w2CheckSum = ttLib.sfnt.calcChecksum(w2data) |
| normCheckSum = ttLib.sfnt.calcChecksum(normData) |
| self.assertEqual(w2CheckSum, normCheckSum) |
| normCheckSumAdjustment = normFont['head'].checkSumAdjustment |
| self.assertEqual(normCheckSumAdjustment, w2font['head'].checkSumAdjustment) |
| |
| def test_calcSFNTChecksumsLengthsAndOffsets(self): |
| normFont = ttLib.TTFont(BytesIO(normalise_font(self.font, padding=4))) |
| for tag in self.tags: |
| self.writer[tag] = self.font.getTableData(tag) |
| self.writer._normaliseGlyfAndLoca(padding=4) |
| self.writer._setHeadTransformFlag() |
| self.writer.tables = OrderedDict(sorted(self.writer.tables.items())) |
| self.writer._calcSFNTChecksumsLengthsAndOffsets() |
| for tag, entry in normFont.reader.tables.items(): |
| self.assertEqual(entry.offset, self.writer.tables[tag].origOffset) |
| self.assertEqual(entry.length, self.writer.tables[tag].origLength) |
| self.assertEqual(entry.checkSum, self.writer.tables[tag].checkSum) |
| |
| def test_bad_sfntVersion(self): |
| for i in range(self.numTables): |
| self.writer[bytechr(65 + i)*4] = b"\0" |
| self.writer.sfntVersion = 'ZZZZ' |
| with self.assertRaisesRegex(ttLib.TTLibError, "bad sfntVersion"): |
| self.writer.close() |
| |
| def test_calcTotalSize_no_flavorData(self): |
| expected = self.length |
| self.writer.file = BytesIO() |
| for tag in self.tags: |
| self.writer[tag] = self.font.getTableData(tag) |
| self.writer.close() |
| self.assertEqual(expected, self.writer.length) |
| self.assertEqual(expected, self.writer.file.tell()) |
| |
| def test_calcTotalSize_with_metaData(self): |
| expected = self.length + len(self.compressed_metadata) |
| flavorData = self.writer.flavorData = WOFF2FlavorData() |
| flavorData.metaData = self.xml_metadata |
| self.writer.file = BytesIO() |
| for tag in self.tags: |
| self.writer[tag] = self.font.getTableData(tag) |
| self.writer.close() |
| self.assertEqual(expected, self.writer.length) |
| self.assertEqual(expected, self.writer.file.tell()) |
| |
| def test_calcTotalSize_with_privData(self): |
| expected = self.length + len(self.privData) |
| flavorData = self.writer.flavorData = WOFF2FlavorData() |
| flavorData.privData = self.privData |
| self.writer.file = BytesIO() |
| for tag in self.tags: |
| self.writer[tag] = self.font.getTableData(tag) |
| self.writer.close() |
| self.assertEqual(expected, self.writer.length) |
| self.assertEqual(expected, self.writer.file.tell()) |
| |
| def test_calcTotalSize_with_metaData_and_privData(self): |
| metaDataLength = (len(self.compressed_metadata) + 3) & ~3 |
| expected = self.length + metaDataLength + len(self.privData) |
| flavorData = self.writer.flavorData = WOFF2FlavorData() |
| flavorData.metaData = self.xml_metadata |
| flavorData.privData = self.privData |
| self.writer.file = BytesIO() |
| for tag in self.tags: |
| self.writer[tag] = self.font.getTableData(tag) |
| self.writer.close() |
| self.assertEqual(expected, self.writer.length) |
| self.assertEqual(expected, self.writer.file.tell()) |
| |
| def test_getVersion(self): |
| # no version |
| self.assertEqual((0, 0), self.writer._getVersion()) |
| # version from head.fontRevision |
| fontRevision = self.font['head'].fontRevision |
| versionTuple = tuple(int(i) for i in str(fontRevision).split(".")) |
| entry = self.writer.tables['head'] = ttLib.newTable('head') |
| entry.data = self.font.getTableData('head') |
| self.assertEqual(versionTuple, self.writer._getVersion()) |
| # version from writer.flavorData |
| flavorData = self.writer.flavorData = WOFF2FlavorData() |
| flavorData.majorVersion, flavorData.minorVersion = (10, 11) |
| self.assertEqual((10, 11), self.writer._getVersion()) |
| |
| def test_hmtx_trasform(self): |
| tableTransforms = {"glyf", "loca", "hmtx"} |
| |
| writer = WOFF2Writer(BytesIO(), self.numTables, self.font.sfntVersion) |
| writer.flavorData = WOFF2FlavorData(transformedTables=tableTransforms) |
| |
| for tag in self.tags: |
| writer[tag] = self.font.getTableData(tag) |
| writer.close() |
| |
| # enabling hmtx transform has no effect when font has no glyf table |
| self.assertEqual(writer.file.getvalue(), CFF_WOFF2.getvalue()) |
| |
| def test_no_transforms(self): |
| writer = WOFF2Writer(BytesIO(), self.numTables, self.font.sfntVersion) |
| writer.flavorData = WOFF2FlavorData(transformedTables=()) |
| |
| for tag in self.tags: |
| writer[tag] = self.font.getTableData(tag) |
| writer.close() |
| |
| # transforms settings have no effect when font is CFF-flavored, since |
| # all the current transforms only apply to TrueType-flavored fonts. |
| self.assertEqual(writer.file.getvalue(), CFF_WOFF2.getvalue()) |
| |
| class WOFF2WriterTTFTest(WOFF2WriterTest): |
| |
| @classmethod |
| def setUpClass(cls): |
| cls.font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False, flavor="woff2") |
| cls.font.importXML(TTX) |
| cls.tags = sorted(t for t in cls.font.keys() if t != 'GlyphOrder') |
| cls.numTables = len(cls.tags) |
| cls.file = BytesIO(TT_WOFF2.getvalue()) |
| cls.file.seek(0, 2) |
| cls.length = (cls.file.tell() + 3) & ~3 |
| cls.setUpFlavorData() |
| |
| def test_normaliseGlyfAndLoca(self): |
| normTables = {} |
| for tag in ('head', 'loca', 'glyf'): |
| normTables[tag] = normalise_table(self.font, tag, padding=4) |
| for tag in self.tags: |
| tableData = self.font.getTableData(tag) |
| self.writer[tag] = tableData |
| if tag in normTables: |
| self.assertNotEqual(tableData, normTables[tag]) |
| self.writer._normaliseGlyfAndLoca(padding=4) |
| self.writer._setHeadTransformFlag() |
| for tag in normTables: |
| self.assertEqual(self.writer.tables[tag].data, normTables[tag]) |
| |
| def test_hmtx_trasform(self): |
| tableTransforms = {"glyf", "loca", "hmtx"} |
| |
| writer = WOFF2Writer(BytesIO(), self.numTables, self.font.sfntVersion) |
| writer.flavorData = WOFF2FlavorData(transformedTables=tableTransforms) |
| |
| for tag in self.tags: |
| writer[tag] = self.font.getTableData(tag) |
| writer.close() |
| |
| length = len(writer.file.getvalue()) |
| |
| # enabling optional hmtx transform shaves off a few bytes |
| self.assertLess(length, len(TT_WOFF2.getvalue())) |
| |
| def test_no_transforms(self): |
| writer = WOFF2Writer(BytesIO(), self.numTables, self.font.sfntVersion) |
| writer.flavorData = WOFF2FlavorData(transformedTables=()) |
| |
| for tag in self.tags: |
| writer[tag] = self.font.getTableData(tag) |
| writer.close() |
| |
| self.assertNotEqual(writer.file.getvalue(), TT_WOFF2.getvalue()) |
| |
| writer.file.seek(0) |
| reader = WOFF2Reader(writer.file) |
| self.assertEqual(len(reader.flavorData.transformedTables), 0) |
| |
| |
| class WOFF2LocaTableTest(unittest.TestCase): |
| |
| def setUp(self): |
| self.font = font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False) |
| font['head'] = ttLib.newTable('head') |
| font['loca'] = WOFF2LocaTable() |
| font['glyf'] = WOFF2GlyfTable() |
| |
| def test_compile_short_loca(self): |
| locaTable = self.font['loca'] |
| locaTable.set(list(range(0, 0x20000, 2))) |
| self.font['glyf'].indexFormat = 0 |
| locaData = locaTable.compile(self.font) |
| self.assertEqual(len(locaData), 0x20000) |
| |
| def test_compile_short_loca_overflow(self): |
| locaTable = self.font['loca'] |
| locaTable.set(list(range(0x20000 + 1))) |
| self.font['glyf'].indexFormat = 0 |
| with self.assertRaisesRegex( |
| ttLib.TTLibError, "indexFormat is 0 but local offsets > 0x20000"): |
| locaTable.compile(self.font) |
| |
| def test_compile_short_loca_not_multiples_of_2(self): |
| locaTable = self.font['loca'] |
| locaTable.set([1, 3, 5, 7]) |
| self.font['glyf'].indexFormat = 0 |
| with self.assertRaisesRegex(ttLib.TTLibError, "offsets not multiples of 2"): |
| locaTable.compile(self.font) |
| |
| def test_compile_long_loca(self): |
| locaTable = self.font['loca'] |
| locaTable.set(list(range(0x20001))) |
| self.font['glyf'].indexFormat = 1 |
| locaData = locaTable.compile(self.font) |
| self.assertEqual(len(locaData), 0x20001 * 4) |
| |
| def test_compile_set_indexToLocFormat_0(self): |
| locaTable = self.font['loca'] |
| # offsets are all multiples of 2 and max length is < 0x10000 |
| locaTable.set(list(range(0, 0x20000, 2))) |
| locaTable.compile(self.font) |
| newIndexFormat = self.font['head'].indexToLocFormat |
| self.assertEqual(0, newIndexFormat) |
| |
| def test_compile_set_indexToLocFormat_1(self): |
| locaTable = self.font['loca'] |
| # offsets are not multiples of 2 |
| locaTable.set(list(range(10))) |
| locaTable.compile(self.font) |
| newIndexFormat = self.font['head'].indexToLocFormat |
| self.assertEqual(1, newIndexFormat) |
| # max length is >= 0x10000 |
| locaTable.set(list(range(0, 0x20000 + 1, 2))) |
| locaTable.compile(self.font) |
| newIndexFormat = self.font['head'].indexToLocFormat |
| self.assertEqual(1, newIndexFormat) |
| |
| |
| class WOFF2GlyfTableTest(unittest.TestCase): |
| |
| @classmethod |
| def setUpClass(cls): |
| font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False) |
| font.importXML(TTX) |
| cls.tables = {} |
| cls.transformedTags = ('maxp', 'head', 'loca', 'glyf') |
| for tag in reversed(cls.transformedTags): # compile in inverse order |
| cls.tables[tag] = font.getTableData(tag) |
| infile = BytesIO(TT_WOFF2.getvalue()) |
| reader = WOFF2Reader(infile) |
| cls.transformedGlyfData = reader.tables['glyf'].loadData( |
| reader.transformBuffer) |
| cls.glyphOrder = ['.notdef'] + ["glyph%.5d" % i for i in range(1, font['maxp'].numGlyphs)] |
| |
| def setUp(self): |
| self.font = font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False) |
| font.setGlyphOrder(self.glyphOrder) |
| font['head'] = ttLib.newTable('head') |
| font['maxp'] = ttLib.newTable('maxp') |
| font['loca'] = WOFF2LocaTable() |
| font['glyf'] = WOFF2GlyfTable() |
| for tag in self.transformedTags: |
| font[tag].decompile(self.tables[tag], font) |
| |
| def test_reconstruct_glyf_padded_4(self): |
| glyfTable = WOFF2GlyfTable() |
| glyfTable.reconstruct(self.transformedGlyfData, self.font) |
| glyfTable.padding = 4 |
| data = glyfTable.compile(self.font) |
| normGlyfData = normalise_table(self.font, 'glyf', glyfTable.padding) |
| self.assertEqual(normGlyfData, data) |
| |
| def test_reconstruct_glyf_padded_2(self): |
| glyfTable = WOFF2GlyfTable() |
| glyfTable.reconstruct(self.transformedGlyfData, self.font) |
| glyfTable.padding = 2 |
| data = glyfTable.compile(self.font) |
| normGlyfData = normalise_table(self.font, 'glyf', glyfTable.padding) |
| self.assertEqual(normGlyfData, data) |
| |
| def test_reconstruct_glyf_unpadded(self): |
| glyfTable = WOFF2GlyfTable() |
| glyfTable.reconstruct(self.transformedGlyfData, self.font) |
| data = glyfTable.compile(self.font) |
| self.assertEqual(self.tables['glyf'], data) |
| |
| def test_reconstruct_glyf_incorrect_glyphOrder(self): |
| glyfTable = WOFF2GlyfTable() |
| badGlyphOrder = self.font.getGlyphOrder()[:-1] |
| self.font.setGlyphOrder(badGlyphOrder) |
| with self.assertRaisesRegex(ttLib.TTLibError, "incorrect glyphOrder"): |
| glyfTable.reconstruct(self.transformedGlyfData, self.font) |
| |
| def test_reconstruct_glyf_missing_glyphOrder(self): |
| glyfTable = WOFF2GlyfTable() |
| del self.font.glyphOrder |
| numGlyphs = self.font['maxp'].numGlyphs |
| del self.font['maxp'] |
| glyfTable.reconstruct(self.transformedGlyfData, self.font) |
| expected = [".notdef"] |
| expected.extend(["glyph%.5d" % i for i in range(1, numGlyphs)]) |
| self.assertEqual(expected, glyfTable.glyphOrder) |
| |
| def test_reconstruct_loca_padded_4(self): |
| locaTable = self.font['loca'] = WOFF2LocaTable() |
| glyfTable = self.font['glyf'] = WOFF2GlyfTable() |
| glyfTable.reconstruct(self.transformedGlyfData, self.font) |
| glyfTable.padding = 4 |
| glyfTable.compile(self.font) |
| data = locaTable.compile(self.font) |
| normLocaData = normalise_table(self.font, 'loca', glyfTable.padding) |
| self.assertEqual(normLocaData, data) |
| |
| def test_reconstruct_loca_padded_2(self): |
| locaTable = self.font['loca'] = WOFF2LocaTable() |
| glyfTable = self.font['glyf'] = WOFF2GlyfTable() |
| glyfTable.reconstruct(self.transformedGlyfData, self.font) |
| glyfTable.padding = 2 |
| glyfTable.compile(self.font) |
| data = locaTable.compile(self.font) |
| normLocaData = normalise_table(self.font, 'loca', glyfTable.padding) |
| self.assertEqual(normLocaData, data) |
| |
| def test_reconstruct_loca_unpadded(self): |
| locaTable = self.font['loca'] = WOFF2LocaTable() |
| glyfTable = self.font['glyf'] = WOFF2GlyfTable() |
| glyfTable.reconstruct(self.transformedGlyfData, self.font) |
| glyfTable.compile(self.font) |
| data = locaTable.compile(self.font) |
| self.assertEqual(self.tables['loca'], data) |
| |
| def test_reconstruct_glyf_header_not_enough_data(self): |
| with self.assertRaisesRegex(ttLib.TTLibError, "not enough 'glyf' data"): |
| WOFF2GlyfTable().reconstruct(b"", self.font) |
| |
| def test_reconstruct_glyf_table_incorrect_size(self): |
| msg = "incorrect size of transformed 'glyf'" |
| with self.assertRaisesRegex(ttLib.TTLibError, msg): |
| WOFF2GlyfTable().reconstruct(self.transformedGlyfData + b"\x00", self.font) |
| with self.assertRaisesRegex(ttLib.TTLibError, msg): |
| WOFF2GlyfTable().reconstruct(self.transformedGlyfData[:-1], self.font) |
| |
| def test_transform_glyf(self): |
| glyfTable = self.font['glyf'] |
| data = glyfTable.transform(self.font) |
| self.assertEqual(self.transformedGlyfData, data) |
| |
| def test_roundtrip_glyf_reconstruct_and_transform(self): |
| glyfTable = WOFF2GlyfTable() |
| glyfTable.reconstruct(self.transformedGlyfData, self.font) |
| data = glyfTable.transform(self.font) |
| self.assertEqual(self.transformedGlyfData, data) |
| |
| def test_roundtrip_glyf_transform_and_reconstruct(self): |
| glyfTable = self.font['glyf'] |
| transformedData = glyfTable.transform(self.font) |
| newGlyfTable = WOFF2GlyfTable() |
| newGlyfTable.reconstruct(transformedData, self.font) |
| newGlyfTable.padding = 4 |
| reconstructedData = newGlyfTable.compile(self.font) |
| normGlyfData = normalise_table(self.font, 'glyf', newGlyfTable.padding) |
| self.assertEqual(normGlyfData, reconstructedData) |
| |
| |
| @pytest.fixture(scope="module") |
| def fontfile(): |
| |
| class Glyph(object): |
| def __init__(self, empty=False, **kwargs): |
| if not empty: |
| self.draw = partial(self.drawRect, **kwargs) |
| else: |
| self.draw = lambda pen: None |
| |
| @staticmethod |
| def drawRect(pen, xMin, xMax): |
| pen.moveTo((xMin, 0)) |
| pen.lineTo((xMin, 1000)) |
| pen.lineTo((xMax, 1000)) |
| pen.lineTo((xMax, 0)) |
| pen.closePath() |
| |
| class CompositeGlyph(object): |
| def __init__(self, components): |
| self.components = components |
| |
| def draw(self, pen): |
| for baseGlyph, (offsetX, offsetY) in self.components: |
| pen.addComponent(baseGlyph, (1, 0, 0, 1, offsetX, offsetY)) |
| |
| fb = fontBuilder.FontBuilder(unitsPerEm=1000, isTTF=True) |
| fb.setupGlyphOrder( |
| [".notdef", "space", "A", "acutecomb", "Aacute", "zero", "one", "two"] |
| ) |
| fb.setupCharacterMap( |
| { |
| 0x20: "space", |
| 0x41: "A", |
| 0x0301: "acutecomb", |
| 0xC1: "Aacute", |
| 0x30: "zero", |
| 0x31: "one", |
| 0x32: "two", |
| } |
| ) |
| fb.setupHorizontalMetrics( |
| { |
| ".notdef": (500, 50), |
| "space": (600, 0), |
| "A": (550, 40), |
| "acutecomb": (0, -40), |
| "Aacute": (550, 40), |
| "zero": (500, 30), |
| "one": (500, 50), |
| "two": (500, 40), |
| } |
| ) |
| fb.setupHorizontalHeader(ascent=1000, descent=-200) |
| |
| srcGlyphs = { |
| ".notdef": Glyph(xMin=50, xMax=450), |
| "space": Glyph(empty=True), |
| "A": Glyph(xMin=40, xMax=510), |
| "acutecomb": Glyph(xMin=-40, xMax=60), |
| "Aacute": CompositeGlyph([("A", (0, 0)), ("acutecomb", (200, 0))]), |
| "zero": Glyph(xMin=30, xMax=470), |
| "one": Glyph(xMin=50, xMax=450), |
| "two": Glyph(xMin=40, xMax=460), |
| } |
| pen = TTGlyphPen(srcGlyphs) |
| glyphSet = {} |
| for glyphName, glyph in srcGlyphs.items(): |
| glyph.draw(pen) |
| glyphSet[glyphName] = pen.glyph() |
| fb.setupGlyf(glyphSet) |
| |
| fb.setupNameTable( |
| { |
| "familyName": "TestWOFF2", |
| "styleName": "Regular", |
| "uniqueFontIdentifier": "TestWOFF2 Regular; Version 1.000; ABCD", |
| "fullName": "TestWOFF2 Regular", |
| "version": "Version 1.000", |
| "psName": "TestWOFF2-Regular", |
| } |
| ) |
| fb.setupOS2() |
| fb.setupPost() |
| |
| buf = BytesIO() |
| fb.save(buf) |
| buf.seek(0) |
| |
| assert fb.font["maxp"].numGlyphs == 8 |
| assert fb.font["hhea"].numberOfHMetrics == 6 |
| for glyphName in fb.font.getGlyphOrder(): |
| xMin = getattr(fb.font["glyf"][glyphName], "xMin", 0) |
| assert xMin == fb.font["hmtx"][glyphName][1] |
| |
| return buf |
| |
| |
| @pytest.fixture |
| def ttFont(fontfile): |
| return ttLib.TTFont(fontfile, recalcBBoxes=False, recalcTimestamp=False) |
| |
| |
| class WOFF2HmtxTableTest(object): |
| def test_transform_no_sidebearings(self, ttFont): |
| hmtxTable = WOFF2HmtxTable() |
| hmtxTable.metrics = ttFont["hmtx"].metrics |
| |
| data = hmtxTable.transform(ttFont) |
| |
| assert data == ( |
| b"\x03" # 00000011 | bits 0 and 1 are set (no sidebearings arrays) |
| |
| # advanceWidthArray |
| b'\x01\xf4' # .notdef: 500 |
| b'\x02X' # space: 600 |
| b'\x02&' # A: 550 |
| b'\x00\x00' # acutecomb: 0 |
| b'\x02&' # Aacute: 550 |
| b'\x01\xf4' # zero: 500 |
| ) |
| |
| def test_transform_proportional_sidebearings(self, ttFont): |
| hmtxTable = WOFF2HmtxTable() |
| metrics = ttFont["hmtx"].metrics |
| # force one of the proportional glyphs to have its left sidebearing be |
| # different from its xMin (40) |
| metrics["A"] = (550, 39) |
| hmtxTable.metrics = metrics |
| |
| assert ttFont["glyf"]["A"].xMin != metrics["A"][1] |
| |
| data = hmtxTable.transform(ttFont) |
| |
| assert data == ( |
| b"\x02" # 00000010 | bits 0 unset: explicit proportional sidebearings |
| |
| # advanceWidthArray |
| b'\x01\xf4' # .notdef: 500 |
| b'\x02X' # space: 600 |
| b'\x02&' # A: 550 |
| b'\x00\x00' # acutecomb: 0 |
| b'\x02&' # Aacute: 550 |
| b'\x01\xf4' # zero: 500 |
| |
| # lsbArray |
| b'\x002' # .notdef: 50 |
| b'\x00\x00' # space: 0 |
| b"\x00'" # A: 39 (xMin: 40) |
| b'\xff\xd8' # acutecomb: -40 |
| b'\x00(' # Aacute: 40 |
| b'\x00\x1e' # zero: 30 |
| ) |
| |
| def test_transform_monospaced_sidebearings(self, ttFont): |
| hmtxTable = WOFF2HmtxTable() |
| metrics = ttFont["hmtx"].metrics |
| hmtxTable.metrics = metrics |
| |
| # force one of the monospaced glyphs at the end of hmtx table to have |
| # its xMin different from its left sidebearing (50) |
| ttFont["glyf"]["one"].xMin = metrics["one"][1] + 1 |
| |
| data = hmtxTable.transform(ttFont) |
| |
| assert data == ( |
| b"\x01" # 00000001 | bits 1 unset: explicit monospaced sidebearings |
| |
| # advanceWidthArray |
| b'\x01\xf4' # .notdef: 500 |
| b'\x02X' # space: 600 |
| b'\x02&' # A: 550 |
| b'\x00\x00' # acutecomb: 0 |
| b'\x02&' # Aacute: 550 |
| b'\x01\xf4' # zero: 500 |
| |
| # leftSideBearingArray |
| b'\x002' # one: 50 (xMin: 51) |
| b'\x00(' # two: 40 |
| ) |
| |
| def test_transform_not_applicable(self, ttFont): |
| hmtxTable = WOFF2HmtxTable() |
| metrics = ttFont["hmtx"].metrics |
| # force both a proportional and monospaced glyph to have sidebearings |
| # different from the respective xMin coordinates |
| metrics["A"] = (550, 39) |
| metrics["one"] = (500, 51) |
| hmtxTable.metrics = metrics |
| |
| # 'None' signals to fall back using untransformed hmtx table data |
| assert hmtxTable.transform(ttFont) is None |
| |
| def test_reconstruct_no_sidebearings(self, ttFont): |
| hmtxTable = WOFF2HmtxTable() |
| |
| data = ( |
| b"\x03" # 00000011 | bits 0 and 1 are set (no sidebearings arrays) |
| |
| # advanceWidthArray |
| b'\x01\xf4' # .notdef: 500 |
| b'\x02X' # space: 600 |
| b'\x02&' # A: 550 |
| b'\x00\x00' # acutecomb: 0 |
| b'\x02&' # Aacute: 550 |
| b'\x01\xf4' # zero: 500 |
| ) |
| |
| hmtxTable.reconstruct(data, ttFont) |
| |
| assert hmtxTable.metrics == { |
| ".notdef": (500, 50), |
| "space": (600, 0), |
| "A": (550, 40), |
| "acutecomb": (0, -40), |
| "Aacute": (550, 40), |
| "zero": (500, 30), |
| "one": (500, 50), |
| "two": (500, 40), |
| } |
| |
| def test_reconstruct_proportional_sidebearings(self, ttFont): |
| hmtxTable = WOFF2HmtxTable() |
| |
| data = ( |
| b"\x02" # 00000010 | bits 0 unset: explicit proportional sidebearings |
| |
| # advanceWidthArray |
| b'\x01\xf4' # .notdef: 500 |
| b'\x02X' # space: 600 |
| b'\x02&' # A: 550 |
| b'\x00\x00' # acutecomb: 0 |
| b'\x02&' # Aacute: 550 |
| b'\x01\xf4' # zero: 500 |
| |
| # lsbArray |
| b'\x002' # .notdef: 50 |
| b'\x00\x00' # space: 0 |
| b"\x00'" # A: 39 (xMin: 40) |
| b'\xff\xd8' # acutecomb: -40 |
| b'\x00(' # Aacute: 40 |
| b'\x00\x1e' # zero: 30 |
| ) |
| |
| hmtxTable.reconstruct(data, ttFont) |
| |
| assert hmtxTable.metrics == { |
| ".notdef": (500, 50), |
| "space": (600, 0), |
| "A": (550, 39), |
| "acutecomb": (0, -40), |
| "Aacute": (550, 40), |
| "zero": (500, 30), |
| "one": (500, 50), |
| "two": (500, 40), |
| } |
| |
| assert ttFont["glyf"]["A"].xMin == 40 |
| |
| def test_reconstruct_monospaced_sidebearings(self, ttFont): |
| hmtxTable = WOFF2HmtxTable() |
| |
| data = ( |
| b"\x01" # 00000001 | bits 1 unset: explicit monospaced sidebearings |
| |
| # advanceWidthArray |
| b'\x01\xf4' # .notdef: 500 |
| b'\x02X' # space: 600 |
| b'\x02&' # A: 550 |
| b'\x00\x00' # acutecomb: 0 |
| b'\x02&' # Aacute: 550 |
| b'\x01\xf4' # zero: 500 |
| |
| # leftSideBearingArray |
| b'\x003' # one: 51 (xMin: 50) |
| b'\x00(' # two: 40 |
| ) |
| |
| hmtxTable.reconstruct(data, ttFont) |
| |
| assert hmtxTable.metrics == { |
| ".notdef": (500, 50), |
| "space": (600, 0), |
| "A": (550, 40), |
| "acutecomb": (0, -40), |
| "Aacute": (550, 40), |
| "zero": (500, 30), |
| "one": (500, 51), |
| "two": (500, 40), |
| } |
| |
| assert ttFont["glyf"]["one"].xMin == 50 |
| |
| def test_reconstruct_flags_reserved_bits(self): |
| hmtxTable = WOFF2HmtxTable() |
| |
| with pytest.raises( |
| ttLib.TTLibError, match="Bits 2-7 of 'hmtx' flags are reserved" |
| ): |
| hmtxTable.reconstruct(b"\xFF", ttFont=None) |
| |
| def test_reconstruct_flags_required_bits(self): |
| hmtxTable = WOFF2HmtxTable() |
| |
| with pytest.raises(ttLib.TTLibError, match="either bits 0 or 1 .* must set"): |
| hmtxTable.reconstruct(b"\x00", ttFont=None) |
| |
| def test_reconstruct_too_much_data(self, ttFont): |
| ttFont["hhea"].numberOfHMetrics = 2 |
| data = b'\x03\x01\xf4\x02X\x02&' |
| hmtxTable = WOFF2HmtxTable() |
| |
| with pytest.raises(ttLib.TTLibError, match="too much 'hmtx' table data"): |
| hmtxTable.reconstruct(data, ttFont) |
| |
| |
| class WOFF2RoundtripTest(object): |
| @staticmethod |
| def roundtrip(infile): |
| infile.seek(0) |
| ttFont = ttLib.TTFont(infile, recalcBBoxes=False, recalcTimestamp=False) |
| outfile = BytesIO() |
| ttFont.save(outfile) |
| return outfile, ttFont |
| |
| def test_roundtrip_default_transforms(self, ttFont): |
| ttFont.flavor = "woff2" |
| # ttFont.flavorData = None |
| tmp = BytesIO() |
| ttFont.save(tmp) |
| |
| tmp2, ttFont2 = self.roundtrip(tmp) |
| |
| assert tmp.getvalue() == tmp2.getvalue() |
| assert ttFont2.reader.flavorData.transformedTables == {"glyf", "loca"} |
| |
| def test_roundtrip_no_transforms(self, ttFont): |
| ttFont.flavor = "woff2" |
| ttFont.flavorData = WOFF2FlavorData(transformedTables=[]) |
| tmp = BytesIO() |
| ttFont.save(tmp) |
| |
| tmp2, ttFont2 = self.roundtrip(tmp) |
| |
| assert tmp.getvalue() == tmp2.getvalue() |
| assert not ttFont2.reader.flavorData.transformedTables |
| |
| def test_roundtrip_all_transforms(self, ttFont): |
| ttFont.flavor = "woff2" |
| ttFont.flavorData = WOFF2FlavorData(transformedTables=["glyf", "loca", "hmtx"]) |
| tmp = BytesIO() |
| ttFont.save(tmp) |
| |
| tmp2, ttFont2 = self.roundtrip(tmp) |
| |
| assert tmp.getvalue() == tmp2.getvalue() |
| assert ttFont2.reader.flavorData.transformedTables == {"glyf", "loca", "hmtx"} |
| |
| def test_roundtrip_only_hmtx_no_glyf_transform(self, ttFont): |
| ttFont.flavor = "woff2" |
| ttFont.flavorData = WOFF2FlavorData(transformedTables=["hmtx"]) |
| tmp = BytesIO() |
| ttFont.save(tmp) |
| |
| tmp2, ttFont2 = self.roundtrip(tmp) |
| |
| assert tmp.getvalue() == tmp2.getvalue() |
| assert ttFont2.reader.flavorData.transformedTables == {"hmtx"} |
| |
| |
| class MainTest(object): |
| |
| @staticmethod |
| def make_ttf(tmpdir): |
| ttFont = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False) |
| ttFont.importXML(TTX) |
| filename = str(tmpdir / "TestTTF-Regular.ttf") |
| ttFont.save(filename) |
| return filename |
| |
| def test_compress_ttf(self, tmpdir): |
| input_file = self.make_ttf(tmpdir) |
| |
| assert woff2.main(["compress", input_file]) is None |
| |
| assert (tmpdir / "TestTTF-Regular.woff2").check(file=True) |
| |
| def test_compress_ttf_no_glyf_transform(self, tmpdir): |
| input_file = self.make_ttf(tmpdir) |
| |
| assert woff2.main(["compress", "--no-glyf-transform", input_file]) is None |
| |
| assert (tmpdir / "TestTTF-Regular.woff2").check(file=True) |
| |
| def test_compress_ttf_hmtx_transform(self, tmpdir): |
| input_file = self.make_ttf(tmpdir) |
| |
| assert woff2.main(["compress", "--hmtx-transform", input_file]) is None |
| |
| assert (tmpdir / "TestTTF-Regular.woff2").check(file=True) |
| |
| def test_compress_ttf_no_glyf_transform_hmtx_transform(self, tmpdir): |
| input_file = self.make_ttf(tmpdir) |
| |
| assert woff2.main( |
| ["compress", "--no-glyf-transform", "--hmtx-transform", input_file] |
| ) is None |
| |
| assert (tmpdir / "TestTTF-Regular.woff2").check(file=True) |
| |
| def test_compress_output_file(self, tmpdir): |
| input_file = self.make_ttf(tmpdir) |
| output_file = tmpdir / "TestTTF.woff2" |
| |
| assert woff2.main( |
| ["compress", "-o", str(output_file), str(input_file)] |
| ) is None |
| |
| assert output_file.check(file=True) |
| |
| def test_compress_otf(self, tmpdir): |
| ttFont = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False) |
| ttFont.importXML(OTX) |
| input_file = str(tmpdir / "TestOTF-Regular.otf") |
| ttFont.save(input_file) |
| |
| assert woff2.main(["compress", input_file]) is None |
| |
| assert (tmpdir / "TestOTF-Regular.woff2").check(file=True) |
| |
| def test_recompress_woff2_keeps_flavorData(self, tmpdir): |
| woff2_font = ttLib.TTFont(BytesIO(TT_WOFF2.getvalue())) |
| woff2_font.flavorData.privData = b"FOOBAR" |
| woff2_file = tmpdir / "TestTTF-Regular.woff2" |
| woff2_font.save(str(woff2_file)) |
| |
| assert woff2_font.flavorData.transformedTables == {"glyf", "loca"} |
| |
| woff2.main(["compress", "--hmtx-transform", str(woff2_file)]) |
| |
| output_file = tmpdir / "TestTTF-Regular#1.woff2" |
| assert output_file.check(file=True) |
| |
| new_woff2_font = ttLib.TTFont(str(output_file)) |
| |
| assert new_woff2_font.flavorData.transformedTables == {"glyf", "loca", "hmtx"} |
| assert new_woff2_font.flavorData.privData == b"FOOBAR" |
| |
| def test_decompress_ttf(self, tmpdir): |
| input_file = tmpdir / "TestTTF-Regular.woff2" |
| input_file.write_binary(TT_WOFF2.getvalue()) |
| |
| assert woff2.main(["decompress", str(input_file)]) is None |
| |
| assert (tmpdir / "TestTTF-Regular.ttf").check(file=True) |
| |
| def test_decompress_otf(self, tmpdir): |
| input_file = tmpdir / "TestTTF-Regular.woff2" |
| input_file.write_binary(CFF_WOFF2.getvalue()) |
| |
| assert woff2.main(["decompress", str(input_file)]) is None |
| |
| assert (tmpdir / "TestTTF-Regular.otf").check(file=True) |
| |
| def test_decompress_output_file(self, tmpdir): |
| input_file = tmpdir / "TestTTF-Regular.woff2" |
| input_file.write_binary(TT_WOFF2.getvalue()) |
| output_file = tmpdir / "TestTTF.ttf" |
| |
| assert woff2.main( |
| ["decompress", "-o", str(output_file), str(input_file)] |
| ) is None |
| |
| assert output_file.check(file=True) |
| |
| def test_no_subcommand_show_help(self, capsys): |
| with pytest.raises(SystemExit): |
| woff2.main(["--help"]) |
| |
| captured = capsys.readouterr() |
| assert "usage: fonttools ttLib.woff2" in captured.out |
| |
| |
| class Base128Test(unittest.TestCase): |
| |
| def test_unpackBase128(self): |
| self.assertEqual(unpackBase128(b'\x3f\x00\x00'), (63, b"\x00\x00")) |
| self.assertEqual(unpackBase128(b'\x8f\xff\xff\xff\x7f')[0], 4294967295) |
| |
| self.assertRaisesRegex( |
| ttLib.TTLibError, |
| "UIntBase128 value must not start with leading zeros", |
| unpackBase128, b'\x80\x80\x3f') |
| |
| self.assertRaisesRegex( |
| ttLib.TTLibError, |
| "UIntBase128-encoded sequence is longer than 5 bytes", |
| unpackBase128, b'\x8f\xff\xff\xff\xff\x7f') |
| |
| self.assertRaisesRegex( |
| ttLib.TTLibError, |
| r"UIntBase128 value exceeds 2\*\*32-1", |
| unpackBase128, b'\x90\x80\x80\x80\x00') |
| |
| self.assertRaisesRegex( |
| ttLib.TTLibError, |
| "not enough data to unpack UIntBase128", |
| unpackBase128, b'') |
| |
| def test_base128Size(self): |
| self.assertEqual(base128Size(0), 1) |
| self.assertEqual(base128Size(24567), 3) |
| self.assertEqual(base128Size(2**32-1), 5) |
| |
| def test_packBase128(self): |
| self.assertEqual(packBase128(63), b"\x3f") |
| self.assertEqual(packBase128(2**32-1), b'\x8f\xff\xff\xff\x7f') |
| self.assertRaisesRegex( |
| ttLib.TTLibError, |
| r"UIntBase128 format requires 0 <= integer <= 2\*\*32-1", |
| packBase128, 2**32+1) |
| self.assertRaisesRegex( |
| ttLib.TTLibError, |
| r"UIntBase128 format requires 0 <= integer <= 2\*\*32-1", |
| packBase128, -1) |
| |
| |
| class UShort255Test(unittest.TestCase): |
| |
| def test_unpack255UShort(self): |
| self.assertEqual(unpack255UShort(bytechr(252))[0], 252) |
| # some numbers (e.g. 506) can have multiple encodings |
| self.assertEqual( |
| unpack255UShort(struct.pack(b"BB", 254, 0))[0], 506) |
| self.assertEqual( |
| unpack255UShort(struct.pack(b"BB", 255, 253))[0], 506) |
| self.assertEqual( |
| unpack255UShort(struct.pack(b"BBB", 253, 1, 250))[0], 506) |
| |
| self.assertRaisesRegex( |
| ttLib.TTLibError, |
| "not enough data to unpack 255UInt16", |
| unpack255UShort, struct.pack(b"BB", 253, 0)) |
| |
| self.assertRaisesRegex( |
| ttLib.TTLibError, |
| "not enough data to unpack 255UInt16", |
| unpack255UShort, struct.pack(b"B", 254)) |
| |
| self.assertRaisesRegex( |
| ttLib.TTLibError, |
| "not enough data to unpack 255UInt16", |
| unpack255UShort, struct.pack(b"B", 255)) |
| |
| def test_pack255UShort(self): |
| self.assertEqual(pack255UShort(252), b'\xfc') |
| self.assertEqual(pack255UShort(505), b'\xff\xfc') |
| self.assertEqual(pack255UShort(506), b'\xfe\x00') |
| self.assertEqual(pack255UShort(762), b'\xfd\x02\xfa') |
| |
| self.assertRaisesRegex( |
| ttLib.TTLibError, |
| "255UInt16 format requires 0 <= integer <= 65535", |
| pack255UShort, -1) |
| |
| self.assertRaisesRegex( |
| ttLib.TTLibError, |
| "255UInt16 format requires 0 <= integer <= 65535", |
| pack255UShort, 0xFFFF+1) |
| |
| |
| if __name__ == "__main__": |
| import sys |
| sys.exit(unittest.main()) |