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