Spaces:
Running
Running
""" 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.misc.roundTools import otRound | |
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.pens.ttGlyphPen import TTGlyphPen | |
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 _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) -> 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) | |
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 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 {tuple(c) for c in path.contours} != {tuple(c) for c in path2.contours}: | |
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 removeOverlaps( | |
font: ttFont.TTFont, | |
glyphNames: Optional[Iterable[str]] = None, | |
removeHinting: bool = True, | |
ignoreErrors=False, | |
) -> 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") | |
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 | |
# 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 main(args=None): | |
"""Simplify glyphs in TTFont by merging overlapping contours.""" | |
import sys | |
if args is None: | |
args = sys.argv[1:] | |
if len(args) < 2: | |
print( | |
f"usage: fonttools ttLib.removeOverlaps INPUT.ttf OUTPUT.ttf [GLYPHS ...]" | |
) | |
sys.exit(1) | |
src = args[0] | |
dst = args[1] | |
glyphNames = args[2:] or None | |
with ttFont.TTFont(src) as f: | |
removeOverlaps(f, glyphNames) | |
f.save(dst) | |
if __name__ == "__main__": | |
main() | |