From 2f9033b22d30fb3e6330f281fc2444e5e5dedd12 Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Sat, 25 May 2024 18:11:30 +0300 Subject: [PATCH] [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."""