From 2f9033b22d30fb3e6330f281fc2444e5e5dedd12 Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Sat, 25 May 2024 18:11:30 +0300 Subject: [PATCH 1/8] [ttLib.removeOverlaps] Support CFF table --- Lib/fontTools/ttLib/removeOverlaps.py | 160 ++++++++++++++++++++------ 1 file changed, 123 insertions(+), 37 deletions(-) diff --git a/Lib/fontTools/ttLib/removeOverlaps.py b/Lib/fontTools/ttLib/removeOverlaps.py index ea13d4734..3612baabb 100644 --- a/Lib/fontTools/ttLib/removeOverlaps.py +++ b/Lib/fontTools/ttLib/removeOverlaps.py @@ -7,11 +7,14 @@ import itertools import logging from typing import Callable, Iterable, Optional, Mapping -from fontTools.misc.roundTools import otRound +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 @@ -81,6 +84,14 @@ def ttfGlyphFromSkPath(path: pathops.Path) -> _g_l_y_f.Glyph: return glyph +def _charString_from_SkPath( + path: pathops.Path, charString: T2CharString +) -> T2CharString: + t2Pen = T2CharStringPen(width=charString.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: @@ -90,7 +101,11 @@ def _round_path( return rounded_path -def _simplify(path: pathops.Path, debugGlyphName: str) -> pathops.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. @@ -105,7 +120,7 @@ def _simplify(path: pathops.Path, debugGlyphName: str) -> pathops.Path: except pathops.PathOpsError: pass - path = _round_path(path) + path = _round_path(path, round=round) try: path = pathops.simplify(path, clockwise=path.clockwise) log.debug( @@ -124,6 +139,10 @@ def _simplify(path: pathops.Path, debugGlyphName: str) -> pathops.Path: 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, @@ -144,7 +163,7 @@ def removeTTGlyphOverlaps( path2 = _simplify(path, glyphName) # replace TTGlyph if simplified path is different (ignoring contour order) - if {tuple(c) for c in path.contours} != {tuple(c) for c in path2.contours}: + if not _same_path(path, path2): glyfTable[glyphName] = glyph = ttfGlyphFromSkPath(path2) # simplified glyph is always unhinted assert not glyph.program @@ -159,42 +178,15 @@ def removeTTGlyphOverlaps( return False -def removeOverlaps( +def _remove_glyf_overlaps( font: ttFont.TTFont, - glyphNames: Optional[Iterable[str]] = None, - removeHinting: bool = True, - ignoreErrors=False, + glyphNames: Iterable[str], + glyphSet: _TTGlyphMapping, + removeHinting: bool, + ignoreErrors: bool, ) -> None: - """Simplify glyphs in TTFont by merging overlapping contours. - - Overlapping components are first decomposed to simple contours, then merged. - - Currently this only works with TrueType fonts with 'glyf' table. - Raises NotImplementedError if 'glyf' table is 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). - """ - try: - glyfTable = font["glyf"] - except KeyError: - raise NotImplementedError("removeOverlaps currently only works with TTFs") - + glyfTable = font["glyf"] hmtxTable = font["hmtx"] - # wraps the underlying glyf Glyphs, takes care of interfacing with drawing pens - glyphSet = font.getGlyphSet() - - if glyphNames is None: - glyphNames = font.getGlyphOrder() # 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 @@ -225,6 +217,100 @@ def removeOverlaps( log.debug("Removed overlaps for %s glyphs:\n%s", len(modified), " ".join(modified)) +def _remove_charstring_overlaps( + glyphName: str, + glyphSet: _TTGlyphMapping, + cffFontSet: CFFFontSet, + removeHinting: bool, +) -> 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 + + if removeHinting: + raise NotImplementedError( + "Hinting removal is not implemented for CFF fonts yet" + ) + return False + + +def _remove_cff_overlaps( + font: ttFont.TTFont, + glyphNames: Iterable[str], + glyphSet: _TTGlyphMapping, + removeHinting: bool, + ignoreErrors: bool, +) -> None: + cffFontSet = font["CFF "].cff + modified = set() + for glyphName in glyphNames: + try: + if _remove_charstring_overlaps( + glyphName, + glyphSet, + cffFontSet, + 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 removeOverlaps( + font: ttFont.TTFont, + glyphNames: Optional[Iterable[str]] = None, + removeHinting: bool = True, + ignoreErrors: bool = False, +) -> 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). + """ + + 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, glyphNames, glyphSet, removeHinting, ignoreErrors) + + if "CFF " in font: + _remove_cff_overlaps(font, glyphNames, glyphSet, removeHinting, ignoreErrors) + + def main(args=None): """Simplify glyphs in TTFont by merging overlapping contours.""" From 2da78ba158078908f31f6a51d1169469ab2b5dad Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Sat, 25 May 2024 18:16:05 +0300 Subject: [PATCH 2/8] [ttLib.removeOverlaps] Handle removeHinting for CFF table --- Lib/fontTools/ttLib/removeOverlaps.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Lib/fontTools/ttLib/removeOverlaps.py b/Lib/fontTools/ttLib/removeOverlaps.py index 3612baabb..ba81ada97 100644 --- a/Lib/fontTools/ttLib/removeOverlaps.py +++ b/Lib/fontTools/ttLib/removeOverlaps.py @@ -221,7 +221,6 @@ def _remove_charstring_overlaps( glyphName: str, glyphSet: _TTGlyphMapping, cffFontSet: CFFFontSet, - removeHinting: bool, ) -> bool: path = skPathFromGlyph(glyphName, glyphSet) @@ -234,10 +233,6 @@ def _remove_charstring_overlaps( charStrings[glyphName] = _charString_from_SkPath(path2, charStrings[glyphName]) return True - if removeHinting: - raise NotImplementedError( - "Hinting removal is not implemented for CFF fonts yet" - ) return False @@ -256,7 +251,6 @@ def _remove_cff_overlaps( glyphName, glyphSet, cffFontSet, - removeHinting, ): modified.add(glyphName) except RemoveOverlapsError: @@ -264,6 +258,9 @@ def _remove_cff_overlaps( raise log.error("Failed to remove overlaps for '%s'", glyphName) + if removeHinting: + cffFontSet.remove_hints() + log.debug("Removed overlaps for %s glyphs:\n%s", len(modified), " ".join(modified)) From 9268e1cdee0a277ef872fbcd7f87f25694f7e064 Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Sat, 25 May 2024 18:22:22 +0300 Subject: [PATCH 3/8] [ttLib.removeOverlaps] Use argparse --- Lib/fontTools/ttLib/removeOverlaps.py | 31 ++++++++++++++------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/Lib/fontTools/ttLib/removeOverlaps.py b/Lib/fontTools/ttLib/removeOverlaps.py index ba81ada97..41d80c4bb 100644 --- a/Lib/fontTools/ttLib/removeOverlaps.py +++ b/Lib/fontTools/ttLib/removeOverlaps.py @@ -311,24 +311,25 @@ def removeOverlaps( def main(args=None): """Simplify glyphs in TTFont by merging overlapping contours.""" - import sys + import argparse - if args is None: - args = sys.argv[1:] + parser = argparse.ArgumentParser( + "fonttools ttLib.removeOverlaps", description=__doc__ + ) - if len(args) < 2: - print( - f"usage: fonttools ttLib.removeOverlaps INPUT.ttf OUTPUT.ttf [GLYPHS ...]" - ) - sys.exit(1) + 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", + ) + args = parser.parse_args(args) - src = args[0] - dst = args[1] - glyphNames = args[2:] or None - - with ttFont.TTFont(src) as f: - removeOverlaps(f, glyphNames) - f.save(dst) + with ttFont.TTFont(args.input) as f: + removeOverlaps(f, args.glyphs or None) + f.save(args.output) if __name__ == "__main__": From ce8fcfcc4f682d13f78ad5eefab7153a4b01d064 Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Sat, 25 May 2024 18:29:10 +0300 Subject: [PATCH 4/8] [ttLib.removeOverlaps] Add all options to CLI --- Lib/fontTools/ttLib/removeOverlaps.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/ttLib/removeOverlaps.py b/Lib/fontTools/ttLib/removeOverlaps.py index 41d80c4bb..69af15462 100644 --- a/Lib/fontTools/ttLib/removeOverlaps.py +++ b/Lib/fontTools/ttLib/removeOverlaps.py @@ -325,11 +325,27 @@ def main(args=None): 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", + ) args = parser.parse_args(args) - with ttFont.TTFont(args.input) as f: - removeOverlaps(f, args.glyphs or None) - f.save(args.output) + with ttFont.TTFont(args.input) as font: + removeOverlaps( + font=font, + glyphNames=args.glyphs or None, + removeHinting=not args.keep_hinting, + ignoreErrors=args.ignore_errors, + ) + font.save(args.output) if __name__ == "__main__": From 44b15b41f666f160806011f38aa8d46968feb3ec Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Sat, 25 May 2024 18:32:01 +0300 Subject: [PATCH 5/8] [ttLib.removeOverlaps] Remove unused subroutines --- Lib/fontTools/ttLib/removeOverlaps.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Lib/fontTools/ttLib/removeOverlaps.py b/Lib/fontTools/ttLib/removeOverlaps.py index 69af15462..4d5c169ff 100644 --- a/Lib/fontTools/ttLib/removeOverlaps.py +++ b/Lib/fontTools/ttLib/removeOverlaps.py @@ -261,6 +261,8 @@ def _remove_cff_overlaps( if removeHinting: cffFontSet.remove_hints() + cffFontSet.remove_unused_subroutines() + log.debug("Removed overlaps for %s glyphs:\n%s", len(modified), " ".join(modified)) From 945bedd8911bfa2c3cef0ae3be7b6398c4843324 Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Sat, 25 May 2024 18:36:07 +0300 Subject: [PATCH 6/8] [ttLib.removeOverlaps] Do nothing if no glyphs were modified --- Lib/fontTools/ttLib/removeOverlaps.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Lib/fontTools/ttLib/removeOverlaps.py b/Lib/fontTools/ttLib/removeOverlaps.py index 4d5c169ff..ab707e1e0 100644 --- a/Lib/fontTools/ttLib/removeOverlaps.py +++ b/Lib/fontTools/ttLib/removeOverlaps.py @@ -258,6 +258,10 @@ def _remove_cff_overlaps( 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() From 525ab7733a440e7efbd5bea48a227545878ba8f0 Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Sat, 25 May 2024 18:41:36 +0300 Subject: [PATCH 7/8] [ttLib.removeOverlaps] Add removeUnusedSubroutines, default to True --- Lib/fontTools/ttLib/removeOverlaps.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/ttLib/removeOverlaps.py b/Lib/fontTools/ttLib/removeOverlaps.py index ab707e1e0..7be490e69 100644 --- a/Lib/fontTools/ttLib/removeOverlaps.py +++ b/Lib/fontTools/ttLib/removeOverlaps.py @@ -242,6 +242,7 @@ def _remove_cff_overlaps( glyphSet: _TTGlyphMapping, removeHinting: bool, ignoreErrors: bool, + removeUnusedSubroutines: bool = True, ) -> None: cffFontSet = font["CFF "].cff modified = set() @@ -265,7 +266,8 @@ def _remove_cff_overlaps( if removeHinting: cffFontSet.remove_hints() - cffFontSet.remove_unused_subroutines() + if removeUnusedSubroutines: + cffFontSet.remove_unused_subroutines() log.debug("Removed overlaps for %s glyphs:\n%s", len(modified), " ".join(modified)) @@ -275,6 +277,7 @@ def removeOverlaps( glyphNames: Optional[Iterable[str]] = None, removeHinting: bool = True, ignoreErrors: bool = False, + removeUnusedSubroutines: bool = True, ) -> None: """Simplify glyphs in TTFont by merging overlapping contours. @@ -294,6 +297,9 @@ def removeOverlaps( 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: @@ -311,7 +317,14 @@ def removeOverlaps( _remove_glyf_overlaps(font, glyphNames, glyphSet, removeHinting, ignoreErrors) if "CFF " in font: - _remove_cff_overlaps(font, glyphNames, glyphSet, removeHinting, ignoreErrors) + _remove_cff_overlaps( + font, + glyphNames, + glyphSet, + removeHinting, + ignoreErrors, + removeUnusedSubroutines, + ) def main(args=None): @@ -342,6 +355,12 @@ def main(args=None): 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: @@ -350,6 +369,7 @@ def main(args=None): glyphNames=args.glyphs or None, removeHinting=not args.keep_hinting, ignoreErrors=args.ignore_errors, + removeUnusedSubroutines=not args.keep_unused_subroutines, ) font.save(args.output) From 246bede217cd272fa7aaad812bc148b71d66a0fb Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Sat, 25 May 2024 19:08:18 +0300 Subject: [PATCH 8/8] [ttLib.removeOverlaps] Force calling new arguments by name --- Lib/fontTools/ttLib/removeOverlaps.py | 31 ++++++++++++++++++--------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/Lib/fontTools/ttLib/removeOverlaps.py b/Lib/fontTools/ttLib/removeOverlaps.py index 7be490e69..312b56b29 100644 --- a/Lib/fontTools/ttLib/removeOverlaps.py +++ b/Lib/fontTools/ttLib/removeOverlaps.py @@ -104,6 +104,7 @@ def _round_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 @@ -179,6 +180,7 @@ def removeTTGlyphOverlaps( def _remove_glyf_overlaps( + *, font: ttFont.TTFont, glyphNames: Iterable[str], glyphSet: _TTGlyphMapping, @@ -218,6 +220,7 @@ def _remove_glyf_overlaps( def _remove_charstring_overlaps( + *, glyphName: str, glyphSet: _TTGlyphMapping, cffFontSet: CFFFontSet, @@ -237,6 +240,7 @@ def _remove_charstring_overlaps( def _remove_cff_overlaps( + *, font: ttFont.TTFont, glyphNames: Iterable[str], glyphSet: _TTGlyphMapping, @@ -249,9 +253,9 @@ def _remove_cff_overlaps( for glyphName in glyphNames: try: if _remove_charstring_overlaps( - glyphName, - glyphSet, - cffFontSet, + glyphName=glyphName, + glyphSet=glyphSet, + cffFontSet=cffFontSet, ): modified.add(glyphName) except RemoveOverlapsError: @@ -277,6 +281,7 @@ def removeOverlaps( glyphNames: Optional[Iterable[str]] = None, removeHinting: bool = True, ignoreErrors: bool = False, + *, removeUnusedSubroutines: bool = True, ) -> None: """Simplify glyphs in TTFont by merging overlapping contours. @@ -314,16 +319,22 @@ def removeOverlaps( glyphSet = font.getGlyphSet() if "glyf" in font: - _remove_glyf_overlaps(font, glyphNames, glyphSet, removeHinting, ignoreErrors) + _remove_glyf_overlaps( + font=font, + glyphNames=glyphNames, + glyphSet=glyphSet, + removeHinting=removeHinting, + ignoreErrors=ignoreErrors, + ) if "CFF " in font: _remove_cff_overlaps( - font, - glyphNames, - glyphSet, - removeHinting, - ignoreErrors, - removeUnusedSubroutines, + font=font, + glyphNames=glyphNames, + glyphSet=glyphSet, + removeHinting=removeHinting, + ignoreErrors=ignoreErrors, + removeUnusedSubroutines=removeUnusedSubroutines, )