diff --git a/Lib/ufo2ft/constants.py b/Lib/ufo2ft/constants.py index 6f2673949..da8c5a22b 100644 --- a/Lib/ufo2ft/constants.py +++ b/Lib/ufo2ft/constants.py @@ -1,7 +1,7 @@ from types import MappingProxyType SPARSE_TTF_MASTER_TABLES = frozenset( - ["glyf", "head", "hmtx", "loca", "maxp", "post", "vmtx"] + ["glyf", "head", "hmtx", "loca", "maxp", "post", "vmtx", "cvt ", "fpgm", "prep"] ) SPARSE_OTF_MASTER_TABLES = frozenset(["CFF ", "VORG", "head", "hmtx", "maxp", "vmtx"]) @@ -35,9 +35,13 @@ # ] COLR_CLIP_BOXES_KEY = UFO2FT_PREFIX + "colrClipBoxes" +OBJECT_LIBS_KEY = "public.objectLibs" OPENTYPE_CATEGORIES_KEY = "public.openTypeCategories" OPENTYPE_META_KEY = "public.openTypeMeta" - +TRUETYPE_INSTRUCTIONS_KEY = "public.truetype.instructions" +TRUETYPE_METRICS_KEY = "public.truetype.useMyMetrics" +TRUETYPE_OVERLAP_KEY = "public.truetype.overlap" +TRUETYPE_ROUND_KEY = "public.truetype.roundOffsetToGrid" UNICODE_VARIATION_SEQUENCES_KEY = "public.unicodeVariationSequences" COMMON_SCRIPT = "Zyyy" diff --git a/Lib/ufo2ft/instructionCompiler.py b/Lib/ufo2ft/instructionCompiler.py new file mode 100644 index 000000000..989a5159b --- /dev/null +++ b/Lib/ufo2ft/instructionCompiler.py @@ -0,0 +1,324 @@ +from __future__ import annotations + +import array +import logging +from typing import TYPE_CHECKING, Optional + +from fontTools import ttLib +from fontTools.pens.hashPointPen import HashPointPen +from fontTools.ttLib import newTable +from fontTools.ttLib.tables._g_l_y_f import ( + OVERLAP_COMPOUND, + ROUND_XY_TO_GRID, + USE_MY_METRICS, +) + +from ufo2ft.constants import ( + OBJECT_LIBS_KEY, + TRUETYPE_INSTRUCTIONS_KEY, + TRUETYPE_METRICS_KEY, + TRUETYPE_OVERLAP_KEY, + TRUETYPE_ROUND_KEY, +) +from ufo2ft.fontInfoData import intListToNum + +if TYPE_CHECKING: + from fontTools.ttLib.tables._g_l_y_f import Glyph as TTGlyph + from ufoLib2 import Font, Glyph + + +logger = logging.getLogger(__name__) + + +class InstructionCompiler: + def __init__( + self, ufo: Font, otf: ttLib.TTFont, autoUseMyMetrics: bool = True + ) -> None: + self.ufo = ufo + self.otf = otf + if not autoUseMyMetrics: + # If autoUseMyMetrics is False, replace the method with a no-op + self.autoUseMyMetrics = lambda ttGlyph, glyphName: None + + def _check_glyph_hash( + self, glyphName: str, ttglyph: TTGlyph, glyph_hash: Optional[str] + ) -> bool: + """Check if the supplied glyph hash from the ufo matches the current outlines.""" + if glyph_hash is None: + # The glyph hash is required + logger.error( + f"Glyph hash missing, glyph '{glyphName}' will have " + "no instructions in font." + ) + return False + + # Check the glyph hash against the TTGlyph that is being built + + ttwidth = self.otf["hmtx"][glyphName][0] + hash_pen = HashPointPen(ttwidth, self.otf.getGlyphSet()) + ttglyph.drawPoints(hash_pen, self.otf["glyf"]) + + if glyph_hash != hash_pen.hash: + logger.error( + f"The stored hash for glyph '{glyphName}' does not match the TrueType " + "output glyph. Glyph will have no instructions in the font." + ) + return False + return True + + @staticmethod + def _check_tt_data_format(ttdata: dict, name: str) -> None: + """Make sure we understand the format version, currently only version 1 + is supported.""" + formatVersion = ttdata.get("formatVersion", None) + if not isinstance(formatVersion, str): + raise TypeError( + f"Illegal type '{type(formatVersion).__name__}' instead of 'str' for " + f"formatVersion for instructions in {name}." + ) + if formatVersion != "1": + raise NotImplementedError( + f"Unknown formatVersion {formatVersion} for instructions in {name}." + ) + + def _compile_program(self, key: str, table_tag: str) -> None: + """Compile the program for prep or fpgm.""" + assert key in ("controlValueProgram", "fontProgram") + assert table_tag in ("prep", "fpgm") + ttdata = self.ufo.lib.get(TRUETYPE_INSTRUCTIONS_KEY, None) + if ttdata: + self._check_tt_data_format(ttdata, f"lib key '{key}'") + asm = ttdata.get(key, None) + if asm is None: + # The optional key is not there, quit right here + return + if not asm: + # If assembly code is empty, don't bother to add the table + logger.debug( + f"Assembly for table '{table_tag}' is empty, " + "table not added to font." + ) + return + + self.otf[table_tag] = table = ttLib.newTable(table_tag) + table.program = ttLib.tables.ttProgram.Program() + table.program.fromAssembly(asm.splitlines()) + + def compileGlyphInstructions(self, ttGlyph, name) -> None: + """Compile the glyph instructions from the UFO glyph `name` to bytecode + and add it to `ttGlyph`.""" + if name not in self.ufo: + # Skip glyphs that are not in the UFO, e.g. '.notdef' + logger.info( + f"Skipping compilation of instructions for glyph '{name}' because it " + "is not in the input UFO." + ) + return + + glyph = self.ufo[name] + ttdata = glyph.lib.get(TRUETYPE_INSTRUCTIONS_KEY, None) + if ttdata is not None: + self._compile_tt_glyph_program(glyph, ttGlyph, ttdata) + if ttGlyph.isComposite(): + self._set_composite_flags(glyph, ttGlyph) + + def _compile_tt_glyph_program( + self, glyph: Glyph, ttglyph: TTGlyph, ttdata: dict + ) -> None: + self._check_tt_data_format(ttdata, f"glyph '{glyph.name}'") + glyph_hash = ttdata.get("id", None) + if not self._check_glyph_hash(glyph.name, ttglyph, glyph_hash): + return + + # Compile the glyph program + asm = ttdata.get("assembly", None) + if asm is None: + # The "assembly" key is required. + logger.error( + f"Glyph assembly missing, glyph '{glyph.name}' will have " + "no instructions in font." + ) + return + + if not asm: + # If the assembly code is empty, don't bother adding a program + logger.debug(f"Glyph '{glyph.name}' has no instructions.") + return + + ttglyph.program = ttLib.tables.ttProgram.Program() + ttglyph.program.fromAssembly(asm.splitlines()) + + def autoUseMyMetrics(self, ttGlyph, glyphName): + """Set the "USE_MY_METRICS" flag on the first component having the + same advance width as the composite glyph, no transform and no + horizontal shift (but allow it to shift vertically). + This forces the composite glyph to use the possibly hinted horizontal + metrics of the sub-glyph, instead of those from the "hmtx" table. + """ + hmtx = self.otf["hmtx"] + width = hmtx[glyphName][0] + for component in ttGlyph.components: + try: + baseName, transform = component.getComponentInfo() + except AttributeError: + # component uses '{first,second}Pt' instead of 'x' and 'y' + continue + try: + baseMetrics = hmtx[baseName] + except KeyError: + continue # ignore missing components + else: + if baseMetrics[0] == width and transform[:-1] == (1, 0, 0, 1, 0): + component.flags |= USE_MY_METRICS + break + + def _set_composite_flags(self, glyph: Glyph, ttglyph: TTGlyph) -> None: + # Set component flags + + if len(ttglyph.components) != len(glyph.components): + # May happen if nested components have been flattened by a filter + logger.error( + "Number of components differ between UFO and TTF " + f"in glyph '{glyph.name}' ({len(glyph.components)} vs. " + f"{len(ttglyph.components)}, not setting component flags from" + "UFO. They may still be set heuristically." + ) + self.autoUseMyMetrics(ttglyph, glyph.name) + return + + # We need to decide when to set the flags. + # Let's assume if any lib key is not there, or the component + # doesn't have an identifier, we should leave the flags alone. + + # Keep track of which component has the USE_MY_METRICS flag + # and whether any component lib contains the useMyMetrics key + use_my_metrics_comp = None + lib_contains_use_my_metrics_key = False + + for i, c in enumerate(ttglyph.components): + # Set OVERLAP_COMPOUND on the first component only + if i == 0 and TRUETYPE_OVERLAP_KEY in glyph.lib: + if glyph.lib.get(TRUETYPE_OVERLAP_KEY, False): + c.flags |= OVERLAP_COMPOUND + else: + c.flags &= ~OVERLAP_COMPOUND + + # Check if we have information about the current component in the glyph lib + ufo_component_id = glyph.components[i].identifier + if ufo_component_id is None: + # No information about component flags is stored in the UFO. + # We don’t modify the flags. Two flags have already been set elsewhere: + # - ROUND_XY_TO_GRID is set in TTGlyphPointPen.glyph() called from + # OutlineTTFCompiler.compileGlyphs() + # - USE_MY_METRICS is set in OutlineTTFCompiler.setupTable_glyf() + continue + + if ( + OBJECT_LIBS_KEY in glyph.lib + and ufo_component_id in glyph.lib[OBJECT_LIBS_KEY] + and ( + TRUETYPE_ROUND_KEY in glyph.lib[OBJECT_LIBS_KEY][ufo_component_id] + or TRUETYPE_METRICS_KEY + in glyph.lib[OBJECT_LIBS_KEY][ufo_component_id] + ) + ): + component_lib = glyph.lib[OBJECT_LIBS_KEY][ufo_component_id] + + # ROUND_XY_TO_GRID + + # https://github.com/googlefonts/ufo2ft/pull/425 recommends + # to always set the ROUND_XY_TO_GRID flag, so we only + # unset it if explicitly done so in the lib + if not component_lib.get(TRUETYPE_ROUND_KEY, True): + c.flags &= ~ROUND_XY_TO_GRID + + # USE_MY_METRICS + if component_lib.get(TRUETYPE_METRICS_KEY, False): + if use_my_metrics_comp is None: + c.flags |= USE_MY_METRICS + use_my_metrics_comp = ufo_component_id + else: + logger.warning( + f"Ignoring USE_MY_METRICS flag on component {i}, " + f"'{ufo_component_id}' because it has been set on " + f"component '{use_my_metrics_comp}' already " + f"in glyph {glyph.name}." + ) + c.flags &= ~USE_MY_METRICS + else: + c.flags &= ~USE_MY_METRICS + lib_contains_use_my_metrics_key |= TRUETYPE_METRICS_KEY in component_lib + + # If no UFO component has the 'public.truetype.useMyMetrics' key defined + # we try to automatically set it + if not lib_contains_use_my_metrics_key: + self.autoUseMyMetrics(ttglyph, glyph.name) + + def update_maxp(self) -> None: + """Update the maxp table with relevant values from the UFO and compiled + font. + """ + maxp = self.otf["maxp"] + ttdata = self.ufo.lib.get(TRUETYPE_INSTRUCTIONS_KEY, None) + if ttdata: + for name in ( + "maxStorage", + "maxFunctionDefs", + "maxInstructionDefs", + "maxStackElements", + # "maxSizeOfInstructions", # Is recalculated below + "maxZones", + "maxTwilightPoints", + ): + value = ttdata.get(name, None) + if value is not None: + setattr(maxp, name, value) + + # Recalculate maxp.maxSizeOfInstructions + sizes = [ + len(ttglyph.program.getBytecode()) + for ttglyph in self.otf["glyf"].glyphs.values() + if hasattr(ttglyph, "program") + ] + maxp.maxSizeOfInstructions = max(sizes, default=0) + + def setupTable_cvt(self) -> None: + """Make the cvt table.""" + cvts = [] + ttdata = self.ufo.lib.get(TRUETYPE_INSTRUCTIONS_KEY, None) + if ttdata: + self._check_tt_data_format(ttdata, "key 'controlValue'") + cvt_dict = ttdata.get("controlValue", None) + if cvt_dict: + # Convert string keys to int + cvt_dict = {int(k): v for k, v in cvt_dict.items()} + # Find the maximum cvt index. + # We can't just use the dict keys because the cvt must be + # filled consecutively. + max_cvt = max(cvt_dict.keys()) + # Make value list, filling entries for missing keys with 0 + cvts = [cvt_dict.get(i, 0) for i in range(max_cvt + 1)] + + if cvts: + # Only write cvt to font if it contains any values + self.otf["cvt "] = cvt = newTable("cvt ") + cvt.values = array.array("h", cvts) + + def setupTable_fpgm(self) -> None: + self._compile_program("fontProgram", "fpgm") + + def setupTable_gasp(self): + if not self.ufo.info.openTypeGaspRangeRecords: + return + + self.otf["gasp"] = gasp = newTable("gasp") + gasp_ranges = dict() + for record in self.ufo.info.openTypeGaspRangeRecords: + rangeMaxPPEM = record["rangeMaxPPEM"] + behavior_bits = record["rangeGaspBehavior"] + rangeGaspBehavior = intListToNum(behavior_bits, 0, 4) + gasp_ranges[rangeMaxPPEM] = rangeGaspBehavior + gasp.gaspRange = gasp_ranges + + def setupTable_prep(self) -> None: + self._compile_program("controlValueProgram", "prep") diff --git a/Lib/ufo2ft/outlineCompiler.py b/Lib/ufo2ft/outlineCompiler.py index c371bfff9..16615a6bb 100644 --- a/Lib/ufo2ft/outlineCompiler.py +++ b/Lib/ufo2ft/outlineCompiler.py @@ -22,7 +22,7 @@ from fontTools.pens.ttGlyphPen import TTGlyphPointPen from fontTools.ttLib import TTFont, newTable from fontTools.ttLib.standardGlyphOrder import standardGlyphOrder -from fontTools.ttLib.tables._g_l_y_f import USE_MY_METRICS, Glyph +from fontTools.ttLib.tables._g_l_y_f import Glyph from fontTools.ttLib.tables._h_e_a_d import mac_epoch_diff from fontTools.ttLib.tables.O_S_2f_2 import Panose @@ -41,10 +41,12 @@ intListToNum, normalizeStringForPostscript, ) +from ufo2ft.instructionCompiler import InstructionCompiler from ufo2ft.util import ( _copyGlyph, calcCodePageRanges, colrClipBoxQuantization, + getMaxComponentDepth, makeOfficialGlyphOrder, makeUnicodeToGlyphNameMapping, ) @@ -127,6 +129,7 @@ def __init__( self._glyphBoundingBoxes = None self._fontBoundingBox = None self._compiledGlyphs = None + self._maxComponentDepths = None def compile(self): """ @@ -290,19 +293,6 @@ def makeOfficialGlyphOrder(self, glyphOrder): # Table Builders # -------------- - def setupTable_gasp(self): - if "gasp" not in self.tables: - return - - self.otf["gasp"] = gasp = newTable("gasp") - gasp_ranges = dict() - for record in self.ufo.info.openTypeGaspRangeRecords: - rangeMaxPPEM = record["rangeMaxPPEM"] - behavior_bits = record["rangeGaspBehavior"] - rangeGaspBehavior = intListToNum(behavior_bits, 0, 4) - gasp_ranges[rangeMaxPPEM] = rangeGaspBehavior - gasp.gaspRange = gasp_ranges - def setupTable_head(self): """ Make the head table. @@ -1415,7 +1405,14 @@ class OutlineTTFCompiler(BaseOutlineCompiler): """Compile a .ttf font with TrueType outlines.""" sfntVersion = "\000\001\000\000" - tables = BaseOutlineCompiler.tables | {"loca", "gasp", "glyf"} + tables = BaseOutlineCompiler.tables | { + "cvt ", + "fpgm", + "gasp", + "glyf", + "loca", + "prep", + } def compileGlyphs(self): """Compile and return the TrueType glyphs for this font.""" @@ -1452,6 +1449,23 @@ def makeGlyphsBoundingBoxes(self): glyphBoxes[glyphName] = bounds return glyphBoxes + def getMaxComponentDepths(self): + """Collect glyphs max components depths. + + Return a dictionary of non zero max components depth keyed by glyph names. + The max component depth of composite glyphs is 1 or more. + Simple glyphs are not keyed. + """ + if self._maxComponentDepths: + return self._maxComponentDepths + maxComponentDepths = dict() + for name, glyph in self.allGlyphs.items(): + depth = getMaxComponentDepth(glyph, self.allGlyphs) + if depth > 0: + maxComponentDepths[name] = depth + self._maxComponentDepths = maxComponentDepths + return self._maxComponentDepths + def setupTable_maxp(self): """Make the maxp table.""" if "maxp" not in self.tables: @@ -1470,6 +1484,7 @@ def setupTable_maxp(self): maxp.maxComponentElements = max( len(g.components) for g in self.allGlyphs.values() ) + maxp.maxComponentDepth = max(self.getMaxComponentDepths().values(), default=0) def setupTable_post(self): """Make a format 2 post table with the compiler's glyph order.""" @@ -1487,9 +1502,22 @@ def setupTable_post(self): post.glyphOrder = self.glyphOrder def setupOtherTables(self): + self.instructionCompiler = InstructionCompiler( + self.ufo, self.otf, autoUseMyMetrics=self.autoUseMyMetrics + ) + self.setupTable_glyf() - if self.ufo.info.openTypeGaspRangeRecords: - self.setupTable_gasp() + + if "cvt " in self.tables: + self.instructionCompiler.setupTable_cvt() + if "fpgm" in self.tables: + self.instructionCompiler.setupTable_fpgm() + if "gasp" in self.tables: + self.instructionCompiler.setupTable_gasp() + if "prep" in self.tables: + self.instructionCompiler.setupTable_prep() + + self.instructionCompiler.update_maxp() def setupTable_glyf(self): """Make the glyf table.""" @@ -1501,41 +1529,32 @@ def setupTable_glyf(self): glyf.glyphs = {} glyf.glyphOrder = self.glyphOrder - hmtx = self.otf.get("hmtx") ttGlyphs = self.getCompiledGlyphs() - for name in self.glyphOrder: + # Sort the glyphs so that simple glyphs are compiled first, and composite + # glyphs are compiled later. Otherwise the glyph hashes may not be ready + # to calculate when a base glyph of a composite glyph is not in the font yet. + maxComponentDepths = self.getMaxComponentDepths() + for name in sorted(self.glyphOrder, key=lambda n: maxComponentDepths.get(n, 0)): ttGlyph = ttGlyphs[name] - if ttGlyph.isComposite() and hmtx is not None and self.autoUseMyMetrics: - self.autoUseMyMetrics(ttGlyph, name, hmtx) + self.instructionCompiler.compileGlyphInstructions(ttGlyph, name) glyf[name] = ttGlyph # update various maxp fields based on glyf without needing to compile the font if "maxp" in self.otf: self.otf["maxp"].recalc(self.otf) - @staticmethod - def autoUseMyMetrics(ttGlyph, glyphName, hmtx): - """Set the "USE_MY_METRICS" flag on the first component having the - same advance width as the composite glyph, no transform and no - horizontal shift (but allow it to shift vertically). - This forces the composite glyph to use the possibly hinted horizontal - metrics of the sub-glyph, instead of those from the "hmtx" table. - """ - width = hmtx[glyphName][0] - for component in ttGlyph.components: - try: - baseName, transform = component.getComponentInfo() - except AttributeError: - # component uses '{first,second}Pt' instead of 'x' and 'y' - continue - try: - baseMetrics = hmtx[baseName] - except KeyError: - continue # ignore missing components - else: - if baseMetrics[0] == width and transform[:-1] == (1, 0, 0, 1, 0): - component.flags |= USE_MY_METRICS - break + # NOTE: the previous 'autoUseMyMetrics' method was moved to the InstructionCompiler + # This property setter is kept for backward compatibility to support the relatively + # obscure use-case (present in tests) of setting compiler.autoUseMyMetrics = None + # in order to disable the feature. It seems to me it's unlikely that one would like + # actually disable this at all... + @property + def autoUseMyMetrics(self) -> bool: + return getattr(self, "_autoUseMyMetrics", True) + + @autoUseMyMetrics.setter + def autoUseMyMetrics(self, value): + self._autoUseMyMetrics = bool(value) class StubGlyph: diff --git a/tests/conftest.py b/tests/conftest.py index 103618523..fbf93b55f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -102,6 +102,35 @@ def draw_rectangle(pen, x_offset, y_offset): pen.lineTo((0 + x_offset, 10 + y_offset)) pen.closePath() + def add_cvt(font, index): + font.lib["public.truetype.instructions"] = { + "controlValue": {0: 0, 2: 30 + 10 * index, 3: 100 - index**2}, + "formatVersion": "1", + "maxFunctionDefs": 1, + "maxInstructionDefs": 0, + "maxStackElements": 2, + "maxStorage": 0, + "maxTwilightPoints": 0, + "maxZones": 1, + } + + def add_programs(font): + font.lib["public.truetype.instructions"][ + "controlValueProgram" + ] = "PUSHB[ ]\n4 3\nINSTCTRL[ ]" + font.lib["public.truetype.instructions"][ + "fontProgram" + ] = "PUSHB[ ]\n0\nFDEF[ ]\nPOP[ ]\nENDF[ ]" + + def add_glyph_program(glyph, hash): + # The hash must be passed as an argument. We could probably calculate it here, + # but it must match the outline after it has been passed through cu2qu. + glyph.lib["public.truetype.instructions"] = { + "assembly": "PUSHB[ ]\n0 0\nSVTCA[0]\nMDRP[01100]", + "formatVersion": "1", + "id": hash, + } + def draw_something(glyph, number, is_sans): # Ensure Sans and Serif sources are incompatible to make sure that the # DS5 code treats them separately when using e.g. cu2qu. Use some number @@ -122,9 +151,15 @@ def draw_something(glyph, number, is_sans): if source.layerName is not None: continue font = FontClass() + add_cvt(font, index) + if index == 0: + # Add some instructions to the default source + add_programs(font) for name in ("I", "S", "I.narrow", "S.closed", "a"): glyph = font.newGlyph(name) draw_something(glyph, index, "Serif" not in source.filename) + if index == 0: + add_glyph_program(font["a"], "w0l0+0l0+10l10+10l10+0|") font.lib["public.glyphOrder"] = sorted(font.keys()) sources[source.filename] = font diff --git a/tests/data/DSv5/MutatorSansVariable_Weight-TTF.ttx b/tests/data/DSv5/MutatorSansVariable_Weight-TTF.ttx index a2d8d3d14..424f48e43 100644 --- a/tests/data/DSv5/MutatorSansVariable_Weight-TTF.ttx +++ b/tests/data/DSv5/MutatorSansVariable_Weight-TTF.ttx @@ -63,10 +63,10 @@ - + - - + + @@ -141,6 +141,31 @@ + + + PUSHB[ ] + 0 + FDEF[ ] + POP[ ] + ENDF[ ] + + + + + + PUSHB[ ] + 4 3 + INSTCTRL[ ] + + + + + + + + + + @@ -213,7 +238,14 @@ - + + + PUSHB[ ] + 0 0 + SVTCA[0] + MDRP[01100] + + @@ -555,6 +587,17 @@ + + + + + + + + + + + diff --git a/tests/data/DSv5/MutatorSansVariable_Weight_Width-TTF.ttx b/tests/data/DSv5/MutatorSansVariable_Weight_Width-TTF.ttx index 3dee6e6dd..09a64efa3 100644 --- a/tests/data/DSv5/MutatorSansVariable_Weight_Width-TTF.ttx +++ b/tests/data/DSv5/MutatorSansVariable_Weight_Width-TTF.ttx @@ -63,10 +63,10 @@ - + - - + + @@ -141,6 +141,31 @@ + + + PUSHB[ ] + 0 + FDEF[ ] + POP[ ] + ENDF[ ] + + + + + + PUSHB[ ] + 4 3 + INSTCTRL[ ] + + + + + + + + + + @@ -213,7 +238,14 @@ - + + + PUSHB[ ] + 0 0 + SVTCA[0] + MDRP[01100] + + @@ -822,6 +854,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/DSv5/MutatorSansVariable_Width-TTF.ttx b/tests/data/DSv5/MutatorSansVariable_Width-TTF.ttx index 9e4a525d0..6d7354527 100644 --- a/tests/data/DSv5/MutatorSansVariable_Width-TTF.ttx +++ b/tests/data/DSv5/MutatorSansVariable_Width-TTF.ttx @@ -63,10 +63,10 @@ - + - - + + @@ -141,6 +141,31 @@ + + + PUSHB[ ] + 0 + FDEF[ ] + POP[ ] + ENDF[ ] + + + + + + PUSHB[ ] + 4 3 + INSTCTRL[ ] + + + + + + + + + + @@ -213,7 +238,14 @@ - + + + PUSHB[ ] + 0 0 + SVTCA[0] + MDRP[01100] + + @@ -548,6 +580,17 @@ + + + + + + + + + + + diff --git a/tests/data/DSv5/MutatorSerifVariable_Width-TTF.ttx b/tests/data/DSv5/MutatorSerifVariable_Width-TTF.ttx index 090c5ebea..c911761d3 100644 --- a/tests/data/DSv5/MutatorSerifVariable_Width-TTF.ttx +++ b/tests/data/DSv5/MutatorSerifVariable_Width-TTF.ttx @@ -63,9 +63,9 @@ - + - + @@ -141,6 +141,13 @@ + + + + + + + @@ -472,6 +479,17 @@ + + + + + + + + + + + diff --git a/tests/instructionCompiler_test.py b/tests/instructionCompiler_test.py new file mode 100644 index 000000000..e02f5101f --- /dev/null +++ b/tests/instructionCompiler_test.py @@ -0,0 +1,771 @@ +import logging + +import pytest +from cu2qu.ufo import font_to_quadratic +from fontTools.pens.hashPointPen import HashPointPen +from fontTools.ttLib.tables._g_l_y_f import ( + OVERLAP_COMPOUND, + ROUND_XY_TO_GRID, + USE_MY_METRICS, +) +from fontTools.ttLib.ttFont import TTFont + +from ufo2ft.instructionCompiler import InstructionCompiler + +from .outlineCompiler_test import getpath + +TRUETYPE_INSTRUCTIONS_KEY = "public.truetype.instructions" + + +def expect_maxp( + font, + maxStorage=0, + maxFunctionDefs=0, + maxInstructionDefs=0, + maxStackElements=0, + maxSizeOfInstructions=0, + maxZones=1, + maxTwilightPoints=0, +): + maxp = font["maxp"] + assert maxp.maxStorage == maxStorage + assert maxp.maxFunctionDefs == maxFunctionDefs + assert maxp.maxInstructionDefs == maxInstructionDefs + assert maxp.maxStackElements == maxStackElements + assert maxp.maxSizeOfInstructions == maxSizeOfInstructions + assert maxp.maxZones == maxZones + assert maxp.maxTwilightPoints == maxTwilightPoints + + +def get_hash_ufo(glyph, ufo): + hash_pen = HashPointPen(glyph.width, ufo) + glyph.drawPoints(hash_pen) + return hash_pen.hash + + +def get_hash_ttf(glyph_name, ttf): + aw, _lsb = ttf["hmtx"][glyph_name] + gs = ttf.getGlyphSet() + hash_pen = HashPointPen(aw, gs) + ttf["glyf"][glyph_name].drawPoints(hash_pen, ttf["glyf"]) + return hash_pen.hash + + +@pytest.fixture +def quadfont(): + font = TTFont() + font.importXML(getpath("TestFont-TTF-post3.ttx")) + return font + + +@pytest.fixture +def quadufo(FontClass): + font = FontClass(getpath("TestFont.ufo")) + font_to_quadratic(font) + return font + + +@pytest.fixture +def quaduforeversed(FontClass): + font = FontClass(getpath("TestFont.ufo")) + font_to_quadratic(font=font, reverse_direction=True) + return font + + +@pytest.fixture +def testufo(FontClass): + font = FontClass(getpath("TestFont.ufo")) + del font.lib["public.postscriptNames"] + return font + + +class InstructionCompilerTest: + # _check_glyph_hash + + def test_check_glyph_hash_match(self, quaduforeversed, quadfont): + glyph = quaduforeversed["a"] + ufo_hash = get_hash_ufo(glyph, quaduforeversed) + ttglyph = quadfont["glyf"]["a"] + + ic = InstructionCompiler(quaduforeversed, quadfont) + result = ic._check_glyph_hash(glyph.name, ttglyph, ufo_hash) + assert result + + def test_check_glyph_hash_missing(self, quaduforeversed, quadfont): + glyph = quaduforeversed["a"] + + ic = InstructionCompiler(quaduforeversed, quadfont) + result = ic._check_glyph_hash( + glyph.name, + quadfont["glyf"]["a"], + None, + ) + assert not result + + def test_check_glyph_hash_mismatch(self, testufo, quadfont): + glyph = testufo["a"] + ufo_hash = get_hash_ufo(glyph, testufo) + ttglyph = quadfont["glyf"]["a"] + + # The contour direction is reversed in testufo vs. quadfont, so the + # hash should not match + + ic = InstructionCompiler(testufo, quadfont) + result = ic._check_glyph_hash( + glyph.name, + ttglyph, + ufo_hash, + ) + assert not result + + def test_check_glyph_hash_mismatch_composite(self, testufo, quadfont): + glyph = testufo["h"] + ufo_hash = get_hash_ufo(glyph, testufo) + ttglyph = quadfont["glyf"]["h"] + + # The contour direction is reversed in testufo vs. quadfont, so the + # hash should not match + + ic = InstructionCompiler(testufo, quadfont) + result = ic._check_glyph_hash( + glyph.name, + ttglyph, + ufo_hash, + ) + assert not result + + def test_check_glyph_hash_mismatch_width(self, quaduforeversed, quadfont): + glyph = quaduforeversed["a"] + + # Modify the glyph width in the UFO to trigger the mismatch + glyph.width += 10 + + ufo_hash = get_hash_ufo(glyph, quaduforeversed) + ttglyph = quadfont["glyf"]["a"] + + ic = InstructionCompiler(quaduforeversed, quadfont) + result = ic._check_glyph_hash( + glyph.name, + ttglyph, + ufo_hash, + ) + assert not result + + # _check_tt_data_format + + def test_check_tt_data_format_match_str(self): + result = InstructionCompiler._check_tt_data_format( + ttdata={"formatVersion": "1"}, + name="", + ) + assert result is None + + def test_check_tt_data_format_type_error(self): + with pytest.raises( + TypeError, + match=( + "Illegal type 'int' instead of 'str' for formatVersion " + "for instructions in location." + ), + ): + InstructionCompiler._check_tt_data_format( + ttdata={"formatVersion": 1}, # Spec requires a str + name="location", + ) + + def test_check_tt_data_format_mismatch_str(self): + with pytest.raises( + NotImplementedError, + match="Unknown formatVersion 1.5 for instructions in location.", + ): + InstructionCompiler._check_tt_data_format( + ttdata={"formatVersion": "1.5"}, # Maps to the correct int + name="location", + ) + + # _compile_program + + def test_compile_program_no_ttdata(self, quadufo): + # UFO contains no "public.truetype.instructions" lib key + ic = InstructionCompiler(quadufo, TTFont()) + for key, tag in ( + ("controlValueProgram", "prep"), + ("fontProgram", "fpgm"), + ): + ic._compile_program(key=key, table_tag=tag) + assert "fpgm" not in ic.otf + assert "prep" not in ic.otf + + def test_compile_program_no_programs(self, quadufo): + # UFO contains the "public.truetype.instructions" lib key, but the font and + # control value programs are not there. (They are optional) + ic = InstructionCompiler(quadufo, TTFont()) + ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = { + "formatVersion": "1", + } + for key, tag in ( + ("controlValueProgram", "prep"), + ("fontProgram", "fpgm"), + ): + ic._compile_program(key=key, table_tag=tag) + assert "fpgm" not in ic.otf + assert "prep" not in ic.otf + + def test_compile_program_none(self, quadufo): + # UFO contains the "public.truetype.instructions" lib key, but the font and + # control value programs are None. + ic = InstructionCompiler(quadufo, TTFont()) + ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = { + "formatVersion": "1", + "controlValueProgram": None, + "fontProgram": None, + } + for key, tag in ( + ("controlValueProgram", "prep"), + ("fontProgram", "fpgm"), + ): + ic._compile_program(key=key, table_tag=tag) + assert "fpgm" not in ic.otf + assert "prep" not in ic.otf + + def test_compile_program_empty(self, quadufo, caplog): + # UFO contains the "public.truetype.instructions" lib key, but the font and + # control value programs are empty. + ic = InstructionCompiler(quadufo, TTFont()) + ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = { + "formatVersion": "1", + "controlValueProgram": "", + "fontProgram": "", + } + with caplog.at_level(logging.DEBUG, logger="ufo2ft.instructionCompiler"): + for key, tag in ( + ("controlValueProgram", "prep"), + ("fontProgram", "fpgm"), + ): + ic._compile_program(key=key, table_tag=tag) + assert ( + "Assembly for table 'fpgm' is empty, table not added to font." + in caplog.text + ) + assert ( + "Assembly for table 'prep' is empty, table not added to font." + in caplog.text + ) + assert "fpgm" not in ic.otf + assert "prep" not in ic.otf + + def test_compile_program(self, quadufo): + # UFO contains the "public.truetype.instructions" lib key, and the font and + # control value programs are present. + ic = InstructionCompiler(quadufo, TTFont()) + ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = { + "formatVersion": "1", + "controlValueProgram": "PUSHW[]\n511\nSCANCTRL[]", + "fontProgram": "PUSHB[]\n0\nFDEF[]\nPOP[]\nENDF[]", + } + for key, tag in ( + ("controlValueProgram", "prep"), + ("fontProgram", "fpgm"), + ): + ic._compile_program(key=key, table_tag=tag) + + assert "fpgm" in ic.otf + assert "prep" in ic.otf + + # Check if the bytecode is correct, though this may be out of scope + assert ic.otf["fpgm"].program.getBytecode() == b"\xb0\x00\x2C\x21\x2D" + assert ic.otf["prep"].program.getBytecode() == b"\xb8\x01\xff\x85" + + # compileGlyphInstructions + + def test_compileGlyphInstructions_missing_glyph(self, caplog): + # The method logs an info when trying to compile a glyph which is + # missing in the UFO, e.g. '.notdef' + ic = InstructionCompiler(dict(), None) + with caplog.at_level(logging.INFO, logger="ufo2ft.instructionCompiler"): + ic.compileGlyphInstructions(None, "A") + assert "Skipping compilation of instructions for glyph 'A'" in caplog.text + + # _compile_tt_glyph_program + + def test_compile_tt_glyph_program_empty(self, quaduforeversed, quadfont): + # UFO glyph contains no "public.truetype.instructions" lib key + with pytest.raises( + TypeError, + match=( + "Illegal type 'NoneType' instead of 'str' for formatVersion " + "for instructions in glyph 'a'." + ), + ): + InstructionCompiler(quaduforeversed, quadfont)._compile_tt_glyph_program( + glyph=quaduforeversed["a"], + ttglyph=quadfont["glyf"]["a"], + ttdata={}, + ) + + def test_compile_tt_glyph_program_no_asm(self, quaduforeversed, quadfont, caplog): + # UFO glyph contains "public.truetype.instructions" lib key, but no + # assembly code entry + ic = InstructionCompiler(quaduforeversed, quadfont) + + assert not ic.otf["glyf"]["a"].isComposite() + + glyph = ic.ufo["a"] + glyph_hash = get_hash_ufo(glyph, ic.ufo) + + with caplog.at_level(logging.ERROR, logger="ufo2ft.instructionCompiler"): + ic._compile_tt_glyph_program( + glyph=ic.ufo["a"], + ttglyph=ic.otf["glyf"]["a"], + ttdata={ + "formatVersion": "1", + "id": glyph_hash, + # "assembly": "", + }, + ) + assert ( + "Glyph assembly missing, glyph 'a' will have no instructions in font." + in caplog.text + ) + + def test_compile_tt_glyph_program_empty_asm( + self, quaduforeversed, quadfont, caplog + ): + # UFO glyph contains "public.truetype.instructions" lib key, but the + # assembly code entry is empty + ic = InstructionCompiler(quaduforeversed, quadfont) + + assert not ic.otf["glyf"]["a"].isComposite() + + glyph = ic.ufo["a"] + glyph_hash = get_hash_ufo(glyph, ic.ufo) + + with caplog.at_level(logging.DEBUG, logger="ufo2ft.instructionCompiler"): + ic._compile_tt_glyph_program( + glyph=ic.ufo["a"], + ttglyph=ic.otf["glyf"]["a"], + ttdata={ + "formatVersion": "1", + "id": glyph_hash, + "assembly": "", + }, + ) + assert "Glyph 'a' has no instructions." in caplog.text + assert not hasattr(ic.otf["glyf"]["h"], "program") + + def test_compile_tt_glyph_program_empty_asm_composite( + self, quaduforeversed, quadfont + ): + # UFO glyph contains "public.truetype.instructions" lib key, but the + # assembly code entry is empty. The glyph is a composite. + ic = InstructionCompiler(quaduforeversed, quadfont) + + glyph = ic.ufo["h"] + glyph_hash = get_hash_ufo(glyph, ic.ufo) + + assert ic.otf["glyf"]["h"].isComposite() + + ic._compile_tt_glyph_program( + glyph=ic.ufo["h"], + ttglyph=ic.otf["glyf"]["h"], + ttdata={ + "formatVersion": "1", + "id": glyph_hash, + "assembly": "", + }, + ) + # Components must not have an empty program + assert not hasattr(ic.otf["glyf"]["h"], "program") + + def test_compile_tt_glyph_program(self, quaduforeversed, quadfont): + # UFO glyph contains "public.truetype.instructions" lib key, and the + # assembly code entry is present. + ic = InstructionCompiler(quaduforeversed, quadfont) + + assert not ic.otf["glyf"]["a"].isComposite() + + glyph = ic.ufo["a"] + glyph_hash = get_hash_ufo(glyph, ic.ufo) + + ic._compile_tt_glyph_program( + glyph=ic.ufo["a"], + ttglyph=ic.otf["glyf"]["a"], + ttdata={ + "formatVersion": "1", + "id": glyph_hash, + "assembly": "PUSHB[]\n0\nMDAP[1]", + }, + ) + assert ic.otf["glyf"]["a"].program.getBytecode() == b"\xb0\x00\x2f" + + def test_compile_tt_glyph_program_composite(self, quaduforeversed, quadfont): + # UFO glyph contains "public.truetype.instructions" lib key, and the + # assembly code entry is present. The glyph is a composite. + name = "k" # Name of the composite glyph + ic = InstructionCompiler(quaduforeversed, quadfont) + + assert ic.otf["glyf"][name].isComposite() + + glyph_hash = get_hash_ufo(ic.ufo[name], ic.ufo) + + ic._compile_tt_glyph_program( + glyph=ic.ufo[name], + ttglyph=ic.otf["glyf"][name], + ttdata={ + "formatVersion": "1", + "id": glyph_hash, + "assembly": "PUSHB[]\n0\nMDAP[1]", + }, + ) + ttglyph = ic.otf["glyf"][name] + assert hasattr(ttglyph, "program") + assert ttglyph.program.getBytecode() == b"\xb0\x00\x2f" + + # _set_composite_flags + + def test_set_composite_flags_no_ttdata(self, quadufo, quadfont): + name = "h" # Name of the composite glyph + ic = InstructionCompiler(quadufo, quadfont) + + glyph = quadufo[name] + ttglyph = quadfont["glyf"][name] + + ic._set_composite_flags( + glyph=glyph, + ttglyph=ttglyph, + ) + + # Flags have been set by heuristics + assert not ttglyph.components[0].flags & OVERLAP_COMPOUND + assert ttglyph.components[0].flags & ROUND_XY_TO_GRID + assert not ttglyph.components[0].flags & USE_MY_METRICS + assert not ttglyph.components[1].flags & OVERLAP_COMPOUND + assert ttglyph.components[1].flags & ROUND_XY_TO_GRID + assert ttglyph.components[1].flags & USE_MY_METRICS + + def test_set_composite_flags_compound(self, quadufo, quadfont): + name = "k" # Name of the composite glyph + ic = InstructionCompiler(quadufo, quadfont) + + glyph = quadufo[name] + glyph.components[0].identifier = "component0" + glyph.components[1].identifier = "component1" + glyph.lib = {"public.truetype.overlap": True} + ttglyph = quadfont["glyf"][name] + + ic._set_composite_flags( + glyph=glyph, + ttglyph=ttglyph, + ) + # The OVERLAP_COMPOUND flag is only set on 1st component + assert ttglyph.components[0].flags & OVERLAP_COMPOUND + assert not ttglyph.components[1].flags & OVERLAP_COMPOUND + + def test_set_composite_flags_no_compound(self, quadufo, quadfont): + name = "k" # Name of the composite glyph + ic = InstructionCompiler(quadufo, quadfont) + + glyph = quadufo[name] + glyph.components[0].identifier = "component0" + glyph.components[1].identifier = "component1" + glyph.lib = {"public.truetype.overlap": False} + ttglyph = quadfont["glyf"][name] + + ic._set_composite_flags( + glyph=glyph, + ttglyph=ttglyph, + ) + assert not ttglyph.components[0].flags & OVERLAP_COMPOUND + assert not ttglyph.components[1].flags & OVERLAP_COMPOUND + + def test_set_composite_flags(self, quadufo, quadfont): + name = "h" # Name of the composite glyph + ic = InstructionCompiler(quadufo, quadfont) + + glyph = quadufo[name] + glyph.components[0].identifier = "component0" + glyph.components[1].identifier = "component1" + glyph.lib = { + "public.objectLibs": { + "component0": { + "public.truetype.roundOffsetToGrid": False, + "public.truetype.useMyMetrics": False, + }, + "component1": { + "public.truetype.roundOffsetToGrid": True, + "public.truetype.useMyMetrics": True, + }, + }, + } + ttglyph = quadfont["glyf"][name] + + ic._set_composite_flags( + glyph=glyph, + ttglyph=ttglyph, + ) + + assert not ttglyph.components[0].flags & OVERLAP_COMPOUND + assert not ttglyph.components[0].flags & ROUND_XY_TO_GRID + assert not ttglyph.components[0].flags & USE_MY_METRICS + + assert not ttglyph.components[1].flags & OVERLAP_COMPOUND + assert ttglyph.components[1].flags & ROUND_XY_TO_GRID + assert ttglyph.components[1].flags & USE_MY_METRICS + + def test_set_composite_flags_metrics_first_only(self, quadufo, quadfont): + name = "h" # Name of the composite glyph + ic = InstructionCompiler(quadufo, quadfont) + + glyph = quadufo[name] + glyph.components[0].identifier = "component0" + glyph.components[1].identifier = "component1" + glyph.lib = { + "public.objectLibs": { + "component0": { + "public.truetype.useMyMetrics": True, + }, + "component1": { + "public.truetype.useMyMetrics": True, + }, + }, + } + ttglyph = quadfont["glyf"][name] + + ic._set_composite_flags( + glyph=glyph, + ttglyph=ttglyph, + ) + + # Flag on component 1 should have been ignored + assert ttglyph.components[0].flags & USE_MY_METRICS + assert not ttglyph.components[1].flags & USE_MY_METRICS + + def test_set_composite_flags_metrics_no_id(self, quadufo, quadfont): + name = "h" # Name of the composite glyph + ic = InstructionCompiler(quadufo, quadfont) + + glyph = quadufo[name] + # First component has no identifier + glyph.components[0].identifier = None + glyph.components[1].identifier = "component1" + glyph.lib = { + "public.objectLibs": { + "component1": { + "public.truetype.useMyMetrics": False, + }, + }, + } + ttglyph = quadfont["glyf"][name] + + ic._set_composite_flags( + glyph=glyph, + ttglyph=ttglyph, + ) + + # Flag on both components should have been unset + assert not ttglyph.components[0].flags & USE_MY_METRICS + assert not ttglyph.components[1].flags & USE_MY_METRICS + + # update_maxp + + def test_update_maxp_no_ttdata(self, quaduforeversed, quadfont): + ic = InstructionCompiler(quaduforeversed, quadfont) + + ic.update_maxp() + expect_maxp(ic.otf) + + def test_update_maxp(self, quaduforeversed, quadfont): + ic = InstructionCompiler(quaduforeversed, quadfont) + ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = { + "formatVersion": "1", + "maxStorage": 1, + "maxFunctionDefs": 1, + "maxInstructionDefs": 1, + "maxStackElements": 1, + "maxSizeOfInstructions": 1, + "maxZones": 2, + "maxTwilightPoints": 1, + } + # Make a glyph program of size 3 in "a" + self.test_compile_tt_glyph_program(quaduforeversed, quadfont) + ic.update_maxp() + # maxSizeOfInstructions should be 3 because it is calculated from the font + expect_maxp(ic.otf, 1, 1, 1, 1, 3, 2, 1) + + # setupTable_cvt + + def test_setupTable_cvt(self, quaduforeversed, quadfont): + ic = InstructionCompiler(quaduforeversed, quadfont) + ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = { + "formatVersion": "1", + "controlValue": { + "1": 500, + "2": 750, + "3": -250, + }, + } + ic.setupTable_cvt() + assert "cvt " in ic.otf + assert list(ic.otf["cvt "].values) == [0, 500, 750, -250] + + def test_setupTable_cvt_empty(self, quaduforeversed, quadfont): + ic = InstructionCompiler(quaduforeversed, quadfont) + ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = { + "formatVersion": "1", + "controlValue": {}, + } + ic.setupTable_cvt() + assert "cvt " not in ic.otf + + def test_setupTable_cvt_none(self, quaduforeversed, quadfont): + ic = InstructionCompiler(quaduforeversed, quadfont) + ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = { + "formatVersion": "1", + "controlValue": None, + } + ic.setupTable_cvt() + assert "cvt " not in ic.otf + + def test_setupTable_cvt_missing(self, quaduforeversed, quadfont): + ic = InstructionCompiler(quaduforeversed, quadfont) + ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = { + "formatVersion": "1", + } + ic.setupTable_cvt() + assert "cvt " not in ic.otf + + def test_setupTable_cvt_no_ttdata(self, quaduforeversed, quadfont): + ic = InstructionCompiler(quaduforeversed, quadfont) + ic.setupTable_cvt() + assert "cvt " not in ic.otf + + # setupTable_fpgm + + def test_setupTable_fpgm_no_ttdata(self, quadufo): + # UFO contains no "public.truetype.instructions" lib key + ic = InstructionCompiler(quadufo, TTFont()) + ic.setupTable_fpgm() + assert "fpgm" not in ic.otf + + def test_setupTable_fpgm_no_program(self, quadufo): + # UFO contains the "public.truetype.instructions" lib key, but the font and + # control value programs are not there. (They are optional) + ic = InstructionCompiler(quadufo, TTFont()) + ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = { + "formatVersion": "1", + } + ic.setupTable_fpgm() + assert "fpgm" not in ic.otf + + def test_setupTable_fpgm_none(self, quadufo): + # UFO contains the "public.truetype.instructions" lib key, but the font and + # control value programs are None. + ic = InstructionCompiler(quadufo, TTFont()) + ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = { + "formatVersion": "1", + "fontProgram": None, + } + ic.setupTable_fpgm() + assert "fpgm" not in ic.otf + + def test_setupTable_fpgm_empty(self, quadufo): + # UFO contains the "public.truetype.instructions" lib key, but the font and + # control value programs are empty. + ic = InstructionCompiler(quadufo, TTFont()) + ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = { + "formatVersion": "1", + "fontProgram": "", + } + ic.setupTable_fpgm() + assert "fpgm" not in ic.otf + + def test_setupTable_fpgm(self, quadufo): + # UFO contains the "public.truetype.instructions" lib key, and the font and + # control value programs are present. + ic = InstructionCompiler(quadufo, TTFont()) + ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = { + "formatVersion": "1", + "fontProgram": "PUSHB[]\n0\nFDEF[]\nPOP[]\nENDF[]", + } + ic.setupTable_fpgm() + + assert "fpgm" in ic.otf + + # Check if the bytecode is correct, though this may be out of scope + assert ic.otf["fpgm"].program.getBytecode() == b"\xb0\x00\x2C\x21\x2D" + + # setupTable_gasp + + def test_setupTable_gasp(self, testufo): + ic = InstructionCompiler(testufo, TTFont()) + ic.setupTable_gasp() + assert "gasp" in ic.otf + assert ic.otf["gasp"].gaspRange == {7: 10, 65535: 15} + + def test_compile_without_gasp(self, testufo): + testufo.info.openTypeGaspRangeRecords = None + ic = InstructionCompiler(testufo, TTFont()) + ic.setupTable_gasp() + assert "gasp" not in ic.otf + + def test_compile_empty_gasp(self, testufo): + # ignore empty gasp + testufo.info.openTypeGaspRangeRecords = [] + ic = InstructionCompiler(testufo, TTFont()) + ic.setupTable_gasp() + assert "gasp" not in ic.otf + + # setupTable_prep + + def test_setupTable_prep_no_ttdata(self, quadufo): + # UFO contains no "public.truetype.instructions" lib key + ic = InstructionCompiler(quadufo, TTFont()) + ic.setupTable_prep() + assert "prep" not in ic.otf + + def test_setupTable_prep_no_program(self, quadufo): + # UFO contains the "public.truetype.instructions" lib key, but the font and + # control value programs are not there. (They are optional) + ic = InstructionCompiler(quadufo, TTFont()) + ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = { + "formatVersion": "1", + } + ic.setupTable_prep() + assert "prep" not in ic.otf + + def test_setupTable_prep_none(self, quadufo): + # UFO contains the "public.truetype.instructions" lib key, but the font and + # control value programs are None. + ic = InstructionCompiler(quadufo, TTFont()) + ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = { + "formatVersion": "1", + "controlValueProgram": None, + } + ic.setupTable_prep() + assert "prep" not in ic.otf + + def test_setupTable_prep_empty(self, quadufo): + # UFO contains the "public.truetype.instructions" lib key, but the font and + # control value programs are empty. + ic = InstructionCompiler(quadufo, TTFont()) + ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = { + "formatVersion": "1", + "controlValueProgram": "", + } + ic.setupTable_prep() + assert "prep" not in ic.otf + + def test_setupTable_prep(self, quadufo): + # UFO contains the "public.truetype.instructions" lib key, and the font and + # control value programs are present. + ic = InstructionCompiler(quadufo, TTFont()) + ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = { + "formatVersion": "1", + "controlValueProgram": "PUSHW[]\n511\nSCANCTRL[]", + } + ic.setupTable_prep() + + assert "prep" in ic.otf + + # Check if the bytecode is correct, though this may be out of scope + assert ic.otf["prep"].program.getBytecode() == b"\xb8\x01\xff\x85" diff --git a/tests/outlineCompiler_test.py b/tests/outlineCompiler_test.py index 4dd5f0d09..bb357c34e 100644 --- a/tests/outlineCompiler_test.py +++ b/tests/outlineCompiler_test.py @@ -44,6 +44,12 @@ def quadufo(FontClass): return font +@pytest.fixture +def nestedcomponentsufo(FontClass): + font = FontClass(getpath("NestedComponents-Regular.ufo")) + return font + + @pytest.fixture def use_my_metrics_ufo(FontClass): return FontClass(getpath("UseMyMetrics.ufo")) @@ -63,13 +69,6 @@ def emptyufo(FontClass): class OutlineTTFCompilerTest: - def test_setupTable_gasp(self, testufo): - compiler = OutlineTTFCompiler(testufo) - compiler.otf = TTFont() - compiler.setupTable_gasp() - assert "gasp" in compiler.otf - assert compiler.otf["gasp"].gaspRange == {7: 10, 65535: 15} - def test_compile_with_gasp(self, testufo): compiler = OutlineTTFCompiler(testufo) compiler.compile() @@ -97,6 +96,14 @@ def test_makeGlyphsBoundingBoxes(self, quadufo): # float coordinates are rounded, so is the bbox assert compiler.glyphBoundingBoxes["d"] == (90, 77, 211, 197) + def test_getMaxComponentDepths(self, nestedcomponentsufo): + compiler = OutlineTTFCompiler(nestedcomponentsufo) + assert "a" not in compiler.getMaxComponentDepths() + assert "b" not in compiler.getMaxComponentDepths() + assert compiler.getMaxComponentDepths()["c"] == 1 + assert compiler.getMaxComponentDepths()["d"] == 1 + assert compiler.getMaxComponentDepths()["e"] == 2 + def test_autoUseMyMetrics(self, use_my_metrics_ufo): compiler = OutlineTTFCompiler(use_my_metrics_ufo) ttf = compiler.compile()