xref: /aosp_15_r20/external/fonttools/Lib/fontTools/ttLib/removeOverlaps.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
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 Callable, Iterable, Optional, Mapping
9
10from fontTools.misc.roundTools import otRound
11from fontTools.ttLib import ttFont
12from fontTools.ttLib.tables import _g_l_y_f
13from fontTools.ttLib.tables import _h_m_t_x
14from fontTools.pens.ttGlyphPen import TTGlyphPen
15
16import pathops
17
18
19__all__ = ["removeOverlaps"]
20
21
22class RemoveOverlapsError(Exception):
23    pass
24
25
26log = logging.getLogger("fontTools.ttLib.removeOverlaps")
27
28_TTGlyphMapping = Mapping[str, ttFont._TTGlyph]
29
30
31def skPathFromGlyph(glyphName: str, glyphSet: _TTGlyphMapping) -> pathops.Path:
32    path = pathops.Path()
33    pathPen = path.getPen(glyphSet=glyphSet)
34    glyphSet[glyphName].draw(pathPen)
35    return path
36
37
38def skPathFromGlyphComponent(
39    component: _g_l_y_f.GlyphComponent, glyphSet: _TTGlyphMapping
40):
41    baseGlyphName, transformation = component.getComponentInfo()
42    path = skPathFromGlyph(baseGlyphName, glyphSet)
43    return path.transform(*transformation)
44
45
46def componentsOverlap(glyph: _g_l_y_f.Glyph, glyphSet: _TTGlyphMapping) -> bool:
47    if not glyph.isComposite():
48        raise ValueError("This method only works with TrueType composite glyphs")
49    if len(glyph.components) < 2:
50        return False  # single component, no overlaps
51
52    component_paths = {}
53
54    def _get_nth_component_path(index: int) -> pathops.Path:
55        if index not in component_paths:
56            component_paths[index] = skPathFromGlyphComponent(
57                glyph.components[index], glyphSet
58            )
59        return component_paths[index]
60
61    return any(
62        pathops.op(
63            _get_nth_component_path(i),
64            _get_nth_component_path(j),
65            pathops.PathOp.INTERSECTION,
66            fix_winding=False,
67            keep_starting_points=False,
68        )
69        for i, j in itertools.combinations(range(len(glyph.components)), 2)
70    )
71
72
73def ttfGlyphFromSkPath(path: pathops.Path) -> _g_l_y_f.Glyph:
74    # Skia paths have no 'components', no need for glyphSet
75    ttPen = TTGlyphPen(glyphSet=None)
76    path.draw(ttPen)
77    glyph = ttPen.glyph()
78    assert not glyph.isComposite()
79    # compute glyph.xMin (glyfTable parameter unused for non composites)
80    glyph.recalcBounds(glyfTable=None)
81    return glyph
82
83
84def _round_path(
85    path: pathops.Path, round: Callable[[float], float] = otRound
86) -> pathops.Path:
87    rounded_path = pathops.Path()
88    for verb, points in path:
89        rounded_path.add(verb, *((round(p[0]), round(p[1])) for p in points))
90    return rounded_path
91
92
93def _simplify(path: pathops.Path, debugGlyphName: str) -> pathops.Path:
94    # skia-pathops has a bug where it sometimes fails to simplify paths when there
95    # are float coordinates and control points are very close to one another.
96    # Rounding coordinates to integers works around the bug.
97    # Since we are going to round glyf coordinates later on anyway, here it is
98    # ok(-ish) to also round before simplify. Better than failing the whole process
99    # for the entire font.
100    # https://bugs.chromium.org/p/skia/issues/detail?id=11958
101    # https://github.com/google/fonts/issues/3365
102    # TODO(anthrotype): remove once this Skia bug is fixed
103    try:
104        return pathops.simplify(path, clockwise=path.clockwise)
105    except pathops.PathOpsError:
106        pass
107
108    path = _round_path(path)
109    try:
110        path = pathops.simplify(path, clockwise=path.clockwise)
111        log.debug(
112            "skia-pathops failed to simplify '%s' with float coordinates, "
113            "but succeded using rounded integer coordinates",
114            debugGlyphName,
115        )
116        return path
117    except pathops.PathOpsError as e:
118        if log.isEnabledFor(logging.DEBUG):
119            path.dump()
120        raise RemoveOverlapsError(
121            f"Failed to remove overlaps from glyph {debugGlyphName!r}"
122        ) from e
123
124    raise AssertionError("Unreachable")
125
126
127def removeTTGlyphOverlaps(
128    glyphName: str,
129    glyphSet: _TTGlyphMapping,
130    glyfTable: _g_l_y_f.table__g_l_y_f,
131    hmtxTable: _h_m_t_x.table__h_m_t_x,
132    removeHinting: bool = True,
133) -> bool:
134    glyph = glyfTable[glyphName]
135    # decompose composite glyphs only if components overlap each other
136    if (
137        glyph.numberOfContours > 0
138        or glyph.isComposite()
139        and componentsOverlap(glyph, glyphSet)
140    ):
141        path = skPathFromGlyph(glyphName, glyphSet)
142
143        # remove overlaps
144        path2 = _simplify(path, glyphName)
145
146        # replace TTGlyph if simplified path is different (ignoring contour order)
147        if {tuple(c) for c in path.contours} != {tuple(c) for c in path2.contours}:
148            glyfTable[glyphName] = glyph = ttfGlyphFromSkPath(path2)
149            # simplified glyph is always unhinted
150            assert not glyph.program
151            # also ensure hmtx LSB == glyph.xMin so glyph origin is at x=0
152            width, lsb = hmtxTable[glyphName]
153            if lsb != glyph.xMin:
154                hmtxTable[glyphName] = (width, glyph.xMin)
155            return True
156
157    if removeHinting:
158        glyph.removeHinting()
159    return False
160
161
162def removeOverlaps(
163    font: ttFont.TTFont,
164    glyphNames: Optional[Iterable[str]] = None,
165    removeHinting: bool = True,
166    ignoreErrors=False,
167) -> None:
168    """Simplify glyphs in TTFont by merging overlapping contours.
169
170    Overlapping components are first decomposed to simple contours, then merged.
171
172    Currently this only works with TrueType fonts with 'glyf' table.
173    Raises NotImplementedError if 'glyf' table is absent.
174
175    Note that removing overlaps invalidates the hinting. By default we drop hinting
176    from all glyphs whether or not overlaps are removed from a given one, as it would
177    look weird if only some glyphs are left (un)hinted.
178
179    Args:
180        font: input TTFont object, modified in place.
181        glyphNames: optional iterable of glyph names (str) to remove overlaps from.
182            By default, all glyphs in the font are processed.
183        removeHinting (bool): set to False to keep hinting for unmodified glyphs.
184        ignoreErrors (bool): set to True to ignore errors while removing overlaps,
185            thus keeping the tricky glyphs unchanged (fonttools/fonttools#2363).
186    """
187    try:
188        glyfTable = font["glyf"]
189    except KeyError:
190        raise NotImplementedError("removeOverlaps currently only works with TTFs")
191
192    hmtxTable = font["hmtx"]
193    # wraps the underlying glyf Glyphs, takes care of interfacing with drawing pens
194    glyphSet = font.getGlyphSet()
195
196    if glyphNames is None:
197        glyphNames = font.getGlyphOrder()
198
199    # process all simple glyphs first, then composites with increasing component depth,
200    # so that by the time we test for component intersections the respective base glyphs
201    # have already been simplified
202    glyphNames = sorted(
203        glyphNames,
204        key=lambda name: (
205            (
206                glyfTable[name].getCompositeMaxpValues(glyfTable).maxComponentDepth
207                if glyfTable[name].isComposite()
208                else 0
209            ),
210            name,
211        ),
212    )
213    modified = set()
214    for glyphName in glyphNames:
215        try:
216            if removeTTGlyphOverlaps(
217                glyphName, glyphSet, glyfTable, hmtxTable, removeHinting
218            ):
219                modified.add(glyphName)
220        except RemoveOverlapsError:
221            if not ignoreErrors:
222                raise
223            log.error("Failed to remove overlaps for '%s'", glyphName)
224
225    log.debug("Removed overlaps for %s glyphs:\n%s", len(modified), " ".join(modified))
226
227
228def main(args=None):
229    import sys
230
231    if args is None:
232        args = sys.argv[1:]
233
234    if len(args) < 2:
235        print(
236            f"usage: fonttools ttLib.removeOverlaps INPUT.ttf OUTPUT.ttf [GLYPHS ...]"
237        )
238        sys.exit(1)
239
240    src = args[0]
241    dst = args[1]
242    glyphNames = args[2:] or None
243
244    with ttFont.TTFont(src) as f:
245        removeOverlaps(f, glyphNames)
246        f.save(dst)
247
248
249if __name__ == "__main__":
250    main()
251