• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1""" Simplify TrueType glyphs by merging overlapping contours/components.
2
3Requires https://github.com/fonttools/skia-pathops
4"""
5
6import itertools
7import logging
8from typing import Iterable, Optional, Mapping
9
10from fontTools.ttLib import ttFont
11from fontTools.ttLib.tables import _g_l_y_f
12from fontTools.ttLib.tables import _h_m_t_x
13from fontTools.pens.ttGlyphPen import TTGlyphPen
14
15import pathops
16
17
18__all__ = ["removeOverlaps"]
19
20
21log = logging.getLogger("fontTools.ttLib.removeOverlaps")
22
23_TTGlyphMapping = Mapping[str, ttFont._TTGlyph]
24
25
26def skPathFromGlyph(glyphName: str, glyphSet: _TTGlyphMapping) -> pathops.Path:
27    path = pathops.Path()
28    pathPen = path.getPen(glyphSet=glyphSet)
29    glyphSet[glyphName].draw(pathPen)
30    return path
31
32
33def skPathFromGlyphComponent(
34    component: _g_l_y_f.GlyphComponent, glyphSet: _TTGlyphMapping
35):
36    baseGlyphName, transformation = component.getComponentInfo()
37    path = skPathFromGlyph(baseGlyphName, glyphSet)
38    return path.transform(*transformation)
39
40
41def componentsOverlap(glyph: _g_l_y_f.Glyph, glyphSet: _TTGlyphMapping) -> bool:
42    if not glyph.isComposite():
43        raise ValueError("This method only works with TrueType composite glyphs")
44    if len(glyph.components) < 2:
45        return False  # single component, no overlaps
46
47    component_paths = {}
48
49    def _get_nth_component_path(index: int) -> pathops.Path:
50        if index not in component_paths:
51            component_paths[index] = skPathFromGlyphComponent(
52                glyph.components[index], glyphSet
53            )
54        return component_paths[index]
55
56    return any(
57        pathops.op(
58            _get_nth_component_path(i),
59            _get_nth_component_path(j),
60            pathops.PathOp.INTERSECTION,
61            fix_winding=False,
62            keep_starting_points=False,
63        )
64        for i, j in itertools.combinations(range(len(glyph.components)), 2)
65    )
66
67
68def ttfGlyphFromSkPath(path: pathops.Path) -> _g_l_y_f.Glyph:
69    # Skia paths have no 'components', no need for glyphSet
70    ttPen = TTGlyphPen(glyphSet=None)
71    path.draw(ttPen)
72    glyph = ttPen.glyph()
73    assert not glyph.isComposite()
74    # compute glyph.xMin (glyfTable parameter unused for non composites)
75    glyph.recalcBounds(glyfTable=None)
76    return glyph
77
78
79def removeTTGlyphOverlaps(
80    glyphName: str,
81    glyphSet: _TTGlyphMapping,
82    glyfTable: _g_l_y_f.table__g_l_y_f,
83    hmtxTable: _h_m_t_x.table__h_m_t_x,
84    removeHinting: bool = True,
85) -> bool:
86    glyph = glyfTable[glyphName]
87    # decompose composite glyphs only if components overlap each other
88    if (
89        glyph.numberOfContours > 0
90        or glyph.isComposite()
91        and componentsOverlap(glyph, glyphSet)
92    ):
93        path = skPathFromGlyph(glyphName, glyphSet)
94
95        # remove overlaps
96        path2 = pathops.simplify(path, clockwise=path.clockwise)
97
98        # replace TTGlyph if simplified path is different (ignoring contour order)
99        if {tuple(c) for c in path.contours} != {tuple(c) for c in path2.contours}:
100            glyfTable[glyphName] = glyph = ttfGlyphFromSkPath(path2)
101            # simplified glyph is always unhinted
102            assert not glyph.program
103            # also ensure hmtx LSB == glyph.xMin so glyph origin is at x=0
104            width, lsb = hmtxTable[glyphName]
105            if lsb != glyph.xMin:
106                hmtxTable[glyphName] = (width, glyph.xMin)
107            return True
108
109    if removeHinting:
110        glyph.removeHinting()
111    return False
112
113
114def removeOverlaps(
115    font: ttFont.TTFont,
116    glyphNames: Optional[Iterable[str]] = None,
117    removeHinting: bool = True,
118) -> None:
119    """Simplify glyphs in TTFont by merging overlapping contours.
120
121    Overlapping components are first decomposed to simple contours, then merged.
122
123    Currently this only works with TrueType fonts with 'glyf' table.
124    Raises NotImplementedError if 'glyf' table is absent.
125
126    Note that removing overlaps invalidates the hinting. By default we drop hinting
127    from all glyphs whether or not overlaps are removed from a given one, as it would
128    look weird if only some glyphs are left (un)hinted.
129
130    Args:
131        font: input TTFont object, modified in place.
132        glyphNames: optional iterable of glyph names (str) to remove overlaps from.
133            By default, all glyphs in the font are processed.
134        removeHinting (bool): set to False to keep hinting for unmodified glyphs.
135    """
136    try:
137        glyfTable = font["glyf"]
138    except KeyError:
139        raise NotImplementedError("removeOverlaps currently only works with TTFs")
140
141    hmtxTable = font["hmtx"]
142    # wraps the underlying glyf Glyphs, takes care of interfacing with drawing pens
143    glyphSet = font.getGlyphSet()
144
145    if glyphNames is None:
146        glyphNames = font.getGlyphOrder()
147
148    # process all simple glyphs first, then composites with increasing component depth,
149    # so that by the time we test for component intersections the respective base glyphs
150    # have already been simplified
151    glyphNames = sorted(
152        glyphNames,
153        key=lambda name: (
154            glyfTable[name].getCompositeMaxpValues(glyfTable).maxComponentDepth
155            if glyfTable[name].isComposite()
156            else 0,
157            name,
158        ),
159    )
160    modified = set()
161    for glyphName in glyphNames:
162        if removeTTGlyphOverlaps(
163            glyphName, glyphSet, glyfTable, hmtxTable, removeHinting
164        ):
165            modified.add(glyphName)
166
167    log.debug("Removed overlaps for %s glyphs:\n%s", len(modified), " ".join(modified))
168
169
170def main(args=None):
171    import sys
172
173    if args is None:
174        args = sys.argv[1:]
175
176    if len(args) < 2:
177        print(
178            f"usage: fonttools ttLib.removeOverlaps INPUT.ttf OUTPUT.ttf [GLYPHS ...]"
179        )
180        sys.exit(1)
181
182    src = args[0]
183    dst = args[1]
184    glyphNames = args[2:] or None
185
186    with ttFont.TTFont(src) as f:
187        removeOverlaps(f, glyphNames)
188        f.save(dst)
189
190
191if __name__ == "__main__":
192    main()
193