1""" Simplify TrueType glyphs by merging overlapping contours/components. 2 3Requires https://github.com/fonttools/skia-pathops 4""" 5 6import itertools 7import logging 8from typing import Iterable, Optional, Mapping 9 10from fontTools.ttLib import ttFont 11from fontTools.ttLib.tables import _g_l_y_f 12from fontTools.ttLib.tables import _h_m_t_x 13from fontTools.pens.ttGlyphPen import TTGlyphPen 14 15import pathops 16 17 18__all__ = ["removeOverlaps"] 19 20 21log = logging.getLogger("fontTools.ttLib.removeOverlaps") 22 23_TTGlyphMapping = Mapping[str, ttFont._TTGlyph] 24 25 26def skPathFromGlyph(glyphName: str, glyphSet: _TTGlyphMapping) -> pathops.Path: 27 path = pathops.Path() 28 pathPen = path.getPen(glyphSet=glyphSet) 29 glyphSet[glyphName].draw(pathPen) 30 return path 31 32 33def skPathFromGlyphComponent( 34 component: _g_l_y_f.GlyphComponent, glyphSet: _TTGlyphMapping 35): 36 baseGlyphName, transformation = component.getComponentInfo() 37 path = skPathFromGlyph(baseGlyphName, glyphSet) 38 return path.transform(*transformation) 39 40 41def componentsOverlap(glyph: _g_l_y_f.Glyph, glyphSet: _TTGlyphMapping) -> bool: 42 if not glyph.isComposite(): 43 raise ValueError("This method only works with TrueType composite glyphs") 44 if len(glyph.components) < 2: 45 return False # single component, no overlaps 46 47 component_paths = {} 48 49 def _get_nth_component_path(index: int) -> pathops.Path: 50 if index not in component_paths: 51 component_paths[index] = skPathFromGlyphComponent( 52 glyph.components[index], glyphSet 53 ) 54 return component_paths[index] 55 56 return any( 57 pathops.op( 58 _get_nth_component_path(i), 59 _get_nth_component_path(j), 60 pathops.PathOp.INTERSECTION, 61 fix_winding=False, 62 keep_starting_points=False, 63 ) 64 for i, j in itertools.combinations(range(len(glyph.components)), 2) 65 ) 66 67 68def ttfGlyphFromSkPath(path: pathops.Path) -> _g_l_y_f.Glyph: 69 # Skia paths have no 'components', no need for glyphSet 70 ttPen = TTGlyphPen(glyphSet=None) 71 path.draw(ttPen) 72 glyph = ttPen.glyph() 73 assert not glyph.isComposite() 74 # compute glyph.xMin (glyfTable parameter unused for non composites) 75 glyph.recalcBounds(glyfTable=None) 76 return glyph 77 78 79def removeTTGlyphOverlaps( 80 glyphName: str, 81 glyphSet: _TTGlyphMapping, 82 glyfTable: _g_l_y_f.table__g_l_y_f, 83 hmtxTable: _h_m_t_x.table__h_m_t_x, 84 removeHinting: bool = True, 85) -> bool: 86 glyph = glyfTable[glyphName] 87 # decompose composite glyphs only if components overlap each other 88 if ( 89 glyph.numberOfContours > 0 90 or glyph.isComposite() 91 and componentsOverlap(glyph, glyphSet) 92 ): 93 path = skPathFromGlyph(glyphName, glyphSet) 94 95 # remove overlaps 96 path2 = pathops.simplify(path, clockwise=path.clockwise) 97 98 # replace TTGlyph if simplified path is different (ignoring contour order) 99 if {tuple(c) for c in path.contours} != {tuple(c) for c in path2.contours}: 100 glyfTable[glyphName] = glyph = ttfGlyphFromSkPath(path2) 101 # simplified glyph is always unhinted 102 assert not glyph.program 103 # also ensure hmtx LSB == glyph.xMin so glyph origin is at x=0 104 width, lsb = hmtxTable[glyphName] 105 if lsb != glyph.xMin: 106 hmtxTable[glyphName] = (width, glyph.xMin) 107 return True 108 109 if removeHinting: 110 glyph.removeHinting() 111 return False 112 113 114def removeOverlaps( 115 font: ttFont.TTFont, 116 glyphNames: Optional[Iterable[str]] = None, 117 removeHinting: bool = True, 118) -> None: 119 """Simplify glyphs in TTFont by merging overlapping contours. 120 121 Overlapping components are first decomposed to simple contours, then merged. 122 123 Currently this only works with TrueType fonts with 'glyf' table. 124 Raises NotImplementedError if 'glyf' table is absent. 125 126 Note that removing overlaps invalidates the hinting. By default we drop hinting 127 from all glyphs whether or not overlaps are removed from a given one, as it would 128 look weird if only some glyphs are left (un)hinted. 129 130 Args: 131 font: input TTFont object, modified in place. 132 glyphNames: optional iterable of glyph names (str) to remove overlaps from. 133 By default, all glyphs in the font are processed. 134 removeHinting (bool): set to False to keep hinting for unmodified glyphs. 135 """ 136 try: 137 glyfTable = font["glyf"] 138 except KeyError: 139 raise NotImplementedError("removeOverlaps currently only works with TTFs") 140 141 hmtxTable = font["hmtx"] 142 # wraps the underlying glyf Glyphs, takes care of interfacing with drawing pens 143 glyphSet = font.getGlyphSet() 144 145 if glyphNames is None: 146 glyphNames = font.getGlyphOrder() 147 148 # process all simple glyphs first, then composites with increasing component depth, 149 # so that by the time we test for component intersections the respective base glyphs 150 # have already been simplified 151 glyphNames = sorted( 152 glyphNames, 153 key=lambda name: ( 154 glyfTable[name].getCompositeMaxpValues(glyfTable).maxComponentDepth 155 if glyfTable[name].isComposite() 156 else 0, 157 name, 158 ), 159 ) 160 modified = set() 161 for glyphName in glyphNames: 162 if removeTTGlyphOverlaps( 163 glyphName, glyphSet, glyfTable, hmtxTable, removeHinting 164 ): 165 modified.add(glyphName) 166 167 log.debug("Removed overlaps for %s glyphs:\n%s", len(modified), " ".join(modified)) 168 169 170def main(args=None): 171 import sys 172 173 if args is None: 174 args = sys.argv[1:] 175 176 if len(args) < 2: 177 print( 178 f"usage: fonttools ttLib.removeOverlaps INPUT.ttf OUTPUT.ttf [GLYPHS ...]" 179 ) 180 sys.exit(1) 181 182 src = args[0] 183 dst = args[1] 184 glyphNames = args[2:] or None 185 186 with ttFont.TTFont(src) as f: 187 removeOverlaps(f, glyphNames) 188 f.save(dst) 189 190 191if __name__ == "__main__": 192 main() 193