You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			394 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Python
		
	
			
		
		
	
	
			394 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Python
		
	
""" 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()
 |