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 Callable, Iterable, Optional, Mapping 9 10from fontTools.misc.roundTools import otRound 11from fontTools.ttLib import ttFont 12from fontTools.ttLib.tables import _g_l_y_f 13from fontTools.ttLib.tables import _h_m_t_x 14from fontTools.pens.ttGlyphPen import TTGlyphPen 15 16import pathops 17 18 19__all__ = ["removeOverlaps"] 20 21 22class RemoveOverlapsError(Exception): 23 pass 24 25 26log = logging.getLogger("fontTools.ttLib.removeOverlaps") 27 28_TTGlyphMapping = Mapping[str, ttFont._TTGlyph] 29 30 31def skPathFromGlyph(glyphName: str, glyphSet: _TTGlyphMapping) -> pathops.Path: 32 path = pathops.Path() 33 pathPen = path.getPen(glyphSet=glyphSet) 34 glyphSet[glyphName].draw(pathPen) 35 return path 36 37 38def skPathFromGlyphComponent( 39 component: _g_l_y_f.GlyphComponent, glyphSet: _TTGlyphMapping 40): 41 baseGlyphName, transformation = component.getComponentInfo() 42 path = skPathFromGlyph(baseGlyphName, glyphSet) 43 return path.transform(*transformation) 44 45 46def componentsOverlap(glyph: _g_l_y_f.Glyph, glyphSet: _TTGlyphMapping) -> bool: 47 if not glyph.isComposite(): 48 raise ValueError("This method only works with TrueType composite glyphs") 49 if len(glyph.components) < 2: 50 return False # single component, no overlaps 51 52 component_paths = {} 53 54 def _get_nth_component_path(index: int) -> pathops.Path: 55 if index not in component_paths: 56 component_paths[index] = skPathFromGlyphComponent( 57 glyph.components[index], glyphSet 58 ) 59 return component_paths[index] 60 61 return any( 62 pathops.op( 63 _get_nth_component_path(i), 64 _get_nth_component_path(j), 65 pathops.PathOp.INTERSECTION, 66 fix_winding=False, 67 keep_starting_points=False, 68 ) 69 for i, j in itertools.combinations(range(len(glyph.components)), 2) 70 ) 71 72 73def ttfGlyphFromSkPath(path: pathops.Path) -> _g_l_y_f.Glyph: 74 # Skia paths have no 'components', no need for glyphSet 75 ttPen = TTGlyphPen(glyphSet=None) 76 path.draw(ttPen) 77 glyph = ttPen.glyph() 78 assert not glyph.isComposite() 79 # compute glyph.xMin (glyfTable parameter unused for non composites) 80 glyph.recalcBounds(glyfTable=None) 81 return glyph 82 83 84def _round_path( 85 path: pathops.Path, round: Callable[[float], float] = otRound 86) -> pathops.Path: 87 rounded_path = pathops.Path() 88 for verb, points in path: 89 rounded_path.add(verb, *((round(p[0]), round(p[1])) for p in points)) 90 return rounded_path 91 92 93def _simplify(path: pathops.Path, debugGlyphName: str) -> pathops.Path: 94 # skia-pathops has a bug where it sometimes fails to simplify paths when there 95 # are float coordinates and control points are very close to one another. 96 # Rounding coordinates to integers works around the bug. 97 # Since we are going to round glyf coordinates later on anyway, here it is 98 # ok(-ish) to also round before simplify. Better than failing the whole process 99 # for the entire font. 100 # https://bugs.chromium.org/p/skia/issues/detail?id=11958 101 # https://github.com/google/fonts/issues/3365 102 # TODO(anthrotype): remove once this Skia bug is fixed 103 try: 104 return pathops.simplify(path, clockwise=path.clockwise) 105 except pathops.PathOpsError: 106 pass 107 108 path = _round_path(path) 109 try: 110 path = pathops.simplify(path, clockwise=path.clockwise) 111 log.debug( 112 "skia-pathops failed to simplify '%s' with float coordinates, " 113 "but succeded using rounded integer coordinates", 114 debugGlyphName, 115 ) 116 return path 117 except pathops.PathOpsError as e: 118 if log.isEnabledFor(logging.DEBUG): 119 path.dump() 120 raise RemoveOverlapsError( 121 f"Failed to remove overlaps from glyph {debugGlyphName!r}" 122 ) from e 123 124 raise AssertionError("Unreachable") 125 126 127def removeTTGlyphOverlaps( 128 glyphName: str, 129 glyphSet: _TTGlyphMapping, 130 glyfTable: _g_l_y_f.table__g_l_y_f, 131 hmtxTable: _h_m_t_x.table__h_m_t_x, 132 removeHinting: bool = True, 133) -> bool: 134 glyph = glyfTable[glyphName] 135 # decompose composite glyphs only if components overlap each other 136 if ( 137 glyph.numberOfContours > 0 138 or glyph.isComposite() 139 and componentsOverlap(glyph, glyphSet) 140 ): 141 path = skPathFromGlyph(glyphName, glyphSet) 142 143 # remove overlaps 144 path2 = _simplify(path, glyphName) 145 146 # replace TTGlyph if simplified path is different (ignoring contour order) 147 if {tuple(c) for c in path.contours} != {tuple(c) for c in path2.contours}: 148 glyfTable[glyphName] = glyph = ttfGlyphFromSkPath(path2) 149 # simplified glyph is always unhinted 150 assert not glyph.program 151 # also ensure hmtx LSB == glyph.xMin so glyph origin is at x=0 152 width, lsb = hmtxTable[glyphName] 153 if lsb != glyph.xMin: 154 hmtxTable[glyphName] = (width, glyph.xMin) 155 return True 156 157 if removeHinting: 158 glyph.removeHinting() 159 return False 160 161 162def removeOverlaps( 163 font: ttFont.TTFont, 164 glyphNames: Optional[Iterable[str]] = None, 165 removeHinting: bool = True, 166 ignoreErrors=False, 167) -> None: 168 """Simplify glyphs in TTFont by merging overlapping contours. 169 170 Overlapping components are first decomposed to simple contours, then merged. 171 172 Currently this only works with TrueType fonts with 'glyf' table. 173 Raises NotImplementedError if 'glyf' table is absent. 174 175 Note that removing overlaps invalidates the hinting. By default we drop hinting 176 from all glyphs whether or not overlaps are removed from a given one, as it would 177 look weird if only some glyphs are left (un)hinted. 178 179 Args: 180 font: input TTFont object, modified in place. 181 glyphNames: optional iterable of glyph names (str) to remove overlaps from. 182 By default, all glyphs in the font are processed. 183 removeHinting (bool): set to False to keep hinting for unmodified glyphs. 184 ignoreErrors (bool): set to True to ignore errors while removing overlaps, 185 thus keeping the tricky glyphs unchanged (fonttools/fonttools#2363). 186 """ 187 try: 188 glyfTable = font["glyf"] 189 except KeyError: 190 raise NotImplementedError("removeOverlaps currently only works with TTFs") 191 192 hmtxTable = font["hmtx"] 193 # wraps the underlying glyf Glyphs, takes care of interfacing with drawing pens 194 glyphSet = font.getGlyphSet() 195 196 if glyphNames is None: 197 glyphNames = font.getGlyphOrder() 198 199 # process all simple glyphs first, then composites with increasing component depth, 200 # so that by the time we test for component intersections the respective base glyphs 201 # have already been simplified 202 glyphNames = sorted( 203 glyphNames, 204 key=lambda name: ( 205 ( 206 glyfTable[name].getCompositeMaxpValues(glyfTable).maxComponentDepth 207 if glyfTable[name].isComposite() 208 else 0 209 ), 210 name, 211 ), 212 ) 213 modified = set() 214 for glyphName in glyphNames: 215 try: 216 if removeTTGlyphOverlaps( 217 glyphName, glyphSet, glyfTable, hmtxTable, removeHinting 218 ): 219 modified.add(glyphName) 220 except RemoveOverlapsError: 221 if not ignoreErrors: 222 raise 223 log.error("Failed to remove overlaps for '%s'", glyphName) 224 225 log.debug("Removed overlaps for %s glyphs:\n%s", len(modified), " ".join(modified)) 226 227 228def main(args=None): 229 import sys 230 231 if args is None: 232 args = sys.argv[1:] 233 234 if len(args) < 2: 235 print( 236 f"usage: fonttools ttLib.removeOverlaps INPUT.ttf OUTPUT.ttf [GLYPHS ...]" 237 ) 238 sys.exit(1) 239 240 src = args[0] 241 dst = args[1] 242 glyphNames = args[2:] or None 243 244 with ttFont.TTFont(src) as f: 245 removeOverlaps(f, glyphNames) 246 f.save(dst) 247 248 249if __name__ == "__main__": 250 main() 251