Spaces:
Paused
Paused
| """ Simplify TrueType glyphs by merging overlapping contours/components. | |
| Requires https://github.com/fonttools/skia-pathops | |
| """ | |
| import itertools | |
| import logging | |
| from typing import Callable, Iterable, Optional, Mapping | |
| from fontTools.cffLib import CFFFontSet | |
| from fontTools.ttLib import ttFont | |
| from fontTools.ttLib.tables import _g_l_y_f | |
| from fontTools.ttLib.tables import _h_m_t_x | |
| from fontTools.misc.psCharStrings import T2CharString | |
| from fontTools.misc.roundTools import otRound, noRound | |
| from fontTools.pens.ttGlyphPen import TTGlyphPen | |
| from fontTools.pens.t2CharStringPen import T2CharStringPen | |
| import pathops | |
| __all__ = ["removeOverlaps"] | |
| class RemoveOverlapsError(Exception): | |
| pass | |
| log = logging.getLogger("fontTools.ttLib.removeOverlaps") | |
| _TTGlyphMapping = Mapping[str, ttFont._TTGlyph] | |
| def skPathFromGlyph(glyphName: str, glyphSet: _TTGlyphMapping) -> pathops.Path: | |
| path = pathops.Path() | |
| pathPen = path.getPen(glyphSet=glyphSet) | |
| glyphSet[glyphName].draw(pathPen) | |
| return path | |
| def skPathFromGlyphComponent( | |
| component: _g_l_y_f.GlyphComponent, glyphSet: _TTGlyphMapping | |
| ): | |
| baseGlyphName, transformation = component.getComponentInfo() | |
| path = skPathFromGlyph(baseGlyphName, glyphSet) | |
| return path.transform(*transformation) | |
| def componentsOverlap(glyph: _g_l_y_f.Glyph, glyphSet: _TTGlyphMapping) -> bool: | |
| if not glyph.isComposite(): | |
| raise ValueError("This method only works with TrueType composite glyphs") | |
| if len(glyph.components) < 2: | |
| return False # single component, no overlaps | |
| component_paths = {} | |
| def _get_nth_component_path(index: int) -> pathops.Path: | |
| if index not in component_paths: | |
| component_paths[index] = skPathFromGlyphComponent( | |
| glyph.components[index], glyphSet | |
| ) | |
| return component_paths[index] | |
| return any( | |
| pathops.op( | |
| _get_nth_component_path(i), | |
| _get_nth_component_path(j), | |
| pathops.PathOp.INTERSECTION, | |
| fix_winding=False, | |
| keep_starting_points=False, | |
| ) | |
| for i, j in itertools.combinations(range(len(glyph.components)), 2) | |
| ) | |
| def ttfGlyphFromSkPath(path: pathops.Path) -> _g_l_y_f.Glyph: | |
| # Skia paths have no 'components', no need for glyphSet | |
| ttPen = TTGlyphPen(glyphSet=None) | |
| path.draw(ttPen) | |
| glyph = ttPen.glyph() | |
| assert not glyph.isComposite() | |
| # compute glyph.xMin (glyfTable parameter unused for non composites) | |
| glyph.recalcBounds(glyfTable=None) | |
| return glyph | |
| def _charString_from_SkPath( | |
| path: pathops.Path, charString: T2CharString | |
| ) -> T2CharString: | |
| if charString.width == charString.private.defaultWidthX: | |
| width = None | |
| else: | |
| width = charString.width - charString.private.nominalWidthX | |
| t2Pen = T2CharStringPen(width=width, glyphSet=None) | |
| path.draw(t2Pen) | |
| return t2Pen.getCharString(charString.private, charString.globalSubrs) | |
| def _round_path( | |
| path: pathops.Path, round: Callable[[float], float] = otRound | |
| ) -> pathops.Path: | |
| rounded_path = pathops.Path() | |
| for verb, points in path: | |
| rounded_path.add(verb, *((round(p[0]), round(p[1])) for p in points)) | |
| return rounded_path | |
| def _simplify( | |
| path: pathops.Path, | |
| debugGlyphName: str, | |
| *, | |
| round: Callable[[float], float] = otRound, | |
| ) -> pathops.Path: | |
| # skia-pathops has a bug where it sometimes fails to simplify paths when there | |
| # are float coordinates and control points are very close to one another. | |
| # Rounding coordinates to integers works around the bug. | |
| # Since we are going to round glyf coordinates later on anyway, here it is | |
| # ok(-ish) to also round before simplify. Better than failing the whole process | |
| # for the entire font. | |
| # https://bugs.chromium.org/p/skia/issues/detail?id=11958 | |
| # https://github.com/google/fonts/issues/3365 | |
| # TODO(anthrotype): remove once this Skia bug is fixed | |
| try: | |
| return pathops.simplify(path, clockwise=path.clockwise) | |
| except pathops.PathOpsError: | |
| pass | |
| path = _round_path(path, round=round) | |
| try: | |
| path = pathops.simplify(path, clockwise=path.clockwise) | |
| log.debug( | |
| "skia-pathops failed to simplify '%s' with float coordinates, " | |
| "but succeded using rounded integer coordinates", | |
| debugGlyphName, | |
| ) | |
| return path | |
| except pathops.PathOpsError as e: | |
| if log.isEnabledFor(logging.DEBUG): | |
| path.dump() | |
| raise RemoveOverlapsError( | |
| f"Failed to remove overlaps from glyph {debugGlyphName!r}" | |
| ) from e | |
| raise AssertionError("Unreachable") | |
| def _same_path(path1: pathops.Path, path2: pathops.Path) -> bool: | |
| return {tuple(c) for c in path1.contours} == {tuple(c) for c in path2.contours} | |
| def removeTTGlyphOverlaps( | |
| glyphName: str, | |
| glyphSet: _TTGlyphMapping, | |
| glyfTable: _g_l_y_f.table__g_l_y_f, | |
| hmtxTable: _h_m_t_x.table__h_m_t_x, | |
| removeHinting: bool = True, | |
| ) -> bool: | |
| glyph = glyfTable[glyphName] | |
| # decompose composite glyphs only if components overlap each other | |
| if ( | |
| glyph.numberOfContours > 0 | |
| or glyph.isComposite() | |
| and componentsOverlap(glyph, glyphSet) | |
| ): | |
| path = skPathFromGlyph(glyphName, glyphSet) | |
| # remove overlaps | |
| path2 = _simplify(path, glyphName) | |
| # replace TTGlyph if simplified path is different (ignoring contour order) | |
| if not _same_path(path, path2): | |
| glyfTable[glyphName] = glyph = ttfGlyphFromSkPath(path2) | |
| # simplified glyph is always unhinted | |
| assert not glyph.program | |
| # also ensure hmtx LSB == glyph.xMin so glyph origin is at x=0 | |
| width, lsb = hmtxTable[glyphName] | |
| if lsb != glyph.xMin: | |
| hmtxTable[glyphName] = (width, glyph.xMin) | |
| return True | |
| if removeHinting: | |
| glyph.removeHinting() | |
| return False | |
| def _remove_glyf_overlaps( | |
| *, | |
| font: ttFont.TTFont, | |
| glyphNames: Iterable[str], | |
| glyphSet: _TTGlyphMapping, | |
| removeHinting: bool, | |
| ignoreErrors: bool, | |
| ) -> None: | |
| glyfTable = font["glyf"] | |
| hmtxTable = font["hmtx"] | |
| # process all simple glyphs first, then composites with increasing component depth, | |
| # so that by the time we test for component intersections the respective base glyphs | |
| # have already been simplified | |
| glyphNames = sorted( | |
| glyphNames, | |
| key=lambda name: ( | |
| ( | |
| glyfTable[name].getCompositeMaxpValues(glyfTable).maxComponentDepth | |
| if glyfTable[name].isComposite() | |
| else 0 | |
| ), | |
| name, | |
| ), | |
| ) | |
| modified = set() | |
| for glyphName in glyphNames: | |
| try: | |
| if removeTTGlyphOverlaps( | |
| glyphName, glyphSet, glyfTable, hmtxTable, removeHinting | |
| ): | |
| modified.add(glyphName) | |
| except RemoveOverlapsError: | |
| if not ignoreErrors: | |
| raise | |
| log.error("Failed to remove overlaps for '%s'", glyphName) | |
| log.debug("Removed overlaps for %s glyphs:\n%s", len(modified), " ".join(modified)) | |
| def _remove_charstring_overlaps( | |
| *, | |
| glyphName: str, | |
| glyphSet: _TTGlyphMapping, | |
| cffFontSet: CFFFontSet, | |
| ) -> bool: | |
| path = skPathFromGlyph(glyphName, glyphSet) | |
| # remove overlaps | |
| path2 = _simplify(path, glyphName, round=noRound) | |
| # replace TTGlyph if simplified path is different (ignoring contour order) | |
| if not _same_path(path, path2): | |
| charStrings = cffFontSet[0].CharStrings | |
| charStrings[glyphName] = _charString_from_SkPath(path2, charStrings[glyphName]) | |
| return True | |
| return False | |
| def _remove_cff_overlaps( | |
| *, | |
| font: ttFont.TTFont, | |
| glyphNames: Iterable[str], | |
| glyphSet: _TTGlyphMapping, | |
| removeHinting: bool, | |
| ignoreErrors: bool, | |
| removeUnusedSubroutines: bool = True, | |
| ) -> None: | |
| cffFontSet = font["CFF "].cff | |
| modified = set() | |
| for glyphName in glyphNames: | |
| try: | |
| if _remove_charstring_overlaps( | |
| glyphName=glyphName, | |
| glyphSet=glyphSet, | |
| cffFontSet=cffFontSet, | |
| ): | |
| modified.add(glyphName) | |
| except RemoveOverlapsError: | |
| if not ignoreErrors: | |
| raise | |
| log.error("Failed to remove overlaps for '%s'", glyphName) | |
| if not modified: | |
| log.debug("No overlaps found in the specified CFF glyphs") | |
| return | |
| if removeHinting: | |
| cffFontSet.remove_hints() | |
| if removeUnusedSubroutines: | |
| cffFontSet.remove_unused_subroutines() | |
| log.debug("Removed overlaps for %s glyphs:\n%s", len(modified), " ".join(modified)) | |
| def removeOverlaps( | |
| font: ttFont.TTFont, | |
| glyphNames: Optional[Iterable[str]] = None, | |
| removeHinting: bool = True, | |
| ignoreErrors: bool = False, | |
| *, | |
| removeUnusedSubroutines: bool = True, | |
| ) -> None: | |
| """Simplify glyphs in TTFont by merging overlapping contours. | |
| Overlapping components are first decomposed to simple contours, then merged. | |
| Currently this only works for fonts with 'glyf' or 'CFF ' tables. | |
| Raises NotImplementedError if 'glyf' or 'CFF ' tables are absent. | |
| Note that removing overlaps invalidates the hinting. By default we drop hinting | |
| from all glyphs whether or not overlaps are removed from a given one, as it would | |
| look weird if only some glyphs are left (un)hinted. | |
| Args: | |
| font: input TTFont object, modified in place. | |
| glyphNames: optional iterable of glyph names (str) to remove overlaps from. | |
| By default, all glyphs in the font are processed. | |
| removeHinting (bool): set to False to keep hinting for unmodified glyphs. | |
| ignoreErrors (bool): set to True to ignore errors while removing overlaps, | |
| thus keeping the tricky glyphs unchanged (fonttools/fonttools#2363). | |
| removeUnusedSubroutines (bool): set to False to keep unused subroutines | |
| in CFF table after removing overlaps. Default is to remove them if | |
| any glyphs are modified. | |
| """ | |
| if "glyf" not in font and "CFF " not in font: | |
| raise NotImplementedError( | |
| "No outline data found in the font: missing 'glyf' or 'CFF ' table" | |
| ) | |
| if glyphNames is None: | |
| glyphNames = font.getGlyphOrder() | |
| # Wraps the underlying glyphs, takes care of interfacing with drawing pens | |
| glyphSet = font.getGlyphSet() | |
| if "glyf" in font: | |
| _remove_glyf_overlaps( | |
| font=font, | |
| glyphNames=glyphNames, | |
| glyphSet=glyphSet, | |
| removeHinting=removeHinting, | |
| ignoreErrors=ignoreErrors, | |
| ) | |
| if "CFF " in font: | |
| _remove_cff_overlaps( | |
| font=font, | |
| glyphNames=glyphNames, | |
| glyphSet=glyphSet, | |
| removeHinting=removeHinting, | |
| ignoreErrors=ignoreErrors, | |
| removeUnusedSubroutines=removeUnusedSubroutines, | |
| ) | |
| def main(args=None): | |
| """Simplify glyphs in TTFont by merging overlapping contours.""" | |
| import argparse | |
| parser = argparse.ArgumentParser( | |
| "fonttools ttLib.removeOverlaps", description=__doc__ | |
| ) | |
| parser.add_argument("input", metavar="INPUT.ttf", help="Input font file") | |
| parser.add_argument("output", metavar="OUTPUT.ttf", help="Output font file") | |
| parser.add_argument( | |
| "glyphs", | |
| metavar="GLYPHS", | |
| nargs="*", | |
| help="Optional list of glyph names to remove overlaps from", | |
| ) | |
| parser.add_argument( | |
| "--keep-hinting", | |
| action="store_true", | |
| help="Keep hinting for unmodified glyphs, default is to drop hinting", | |
| ) | |
| parser.add_argument( | |
| "--ignore-errors", | |
| action="store_true", | |
| help="ignore errors while removing overlaps, " | |
| "thus keeping the tricky glyphs unchanged", | |
| ) | |
| parser.add_argument( | |
| "--keep-unused-subroutines", | |
| action="store_true", | |
| help="Keep unused subroutines in CFF table after removing overlaps, " | |
| "default is to remove them if any glyphs are modified", | |
| ) | |
| args = parser.parse_args(args) | |
| with ttFont.TTFont(args.input) as font: | |
| removeOverlaps( | |
| font=font, | |
| glyphNames=args.glyphs or None, | |
| removeHinting=not args.keep_hinting, | |
| ignoreErrors=args.ignore_errors, | |
| removeUnusedSubroutines=not args.keep_unused_subroutines, | |
| ) | |
| font.save(args.output) | |
| if __name__ == "__main__": | |
| main() | |