1"""
2Tool to find wrong contour order between different masters, and
3other interpolatability (or lack thereof) issues.
4
5Call as:
6$ fonttools varLib.interpolatable font1 font2 ...
7"""
8
9from fontTools.pens.basePen import AbstractPen, BasePen
10from fontTools.pens.recordingPen import RecordingPen
11from fontTools.pens.statisticsPen import StatisticsPen
12from fontTools.pens.momentsPen import OpenContourError
13from collections import OrderedDict
14import itertools
15import sys
16
17
18class PerContourPen(BasePen):
19    def __init__(self, Pen, glyphset=None):
20        BasePen.__init__(self, glyphset)
21        self._glyphset = glyphset
22        self._Pen = Pen
23        self._pen = None
24        self.value = []
25
26    def _moveTo(self, p0):
27        self._newItem()
28        self._pen.moveTo(p0)
29
30    def _lineTo(self, p1):
31        self._pen.lineTo(p1)
32
33    def _qCurveToOne(self, p1, p2):
34        self._pen.qCurveTo(p1, p2)
35
36    def _curveToOne(self, p1, p2, p3):
37        self._pen.curveTo(p1, p2, p3)
38
39    def _closePath(self):
40        self._pen.closePath()
41        self._pen = None
42
43    def _endPath(self):
44        self._pen.endPath()
45        self._pen = None
46
47    def _newItem(self):
48        self._pen = pen = self._Pen()
49        self.value.append(pen)
50
51
52class PerContourOrComponentPen(PerContourPen):
53    def addComponent(self, glyphName, transformation):
54        self._newItem()
55        self.value[-1].addComponent(glyphName, transformation)
56
57
58def _vdiff(v0, v1):
59    return tuple(b - a for a, b in zip(v0, v1))
60
61
62def _vlen(vec):
63    v = 0
64    for x in vec:
65        v += x * x
66    return v
67
68
69def _matching_cost(G, matching):
70    return sum(G[i][j] for i, j in enumerate(matching))
71
72
73def min_cost_perfect_bipartite_matching(G):
74    n = len(G)
75    try:
76        from scipy.optimize import linear_sum_assignment
77
78        rows, cols = linear_sum_assignment(G)
79        assert (rows == list(range(n))).all()
80        return list(cols), _matching_cost(G, cols)
81    except ImportError:
82        pass
83
84    try:
85        from munkres import Munkres
86
87        cols = [None] * n
88        for row, col in Munkres().compute(G):
89            cols[row] = col
90        return cols, _matching_cost(G, cols)
91    except ImportError:
92        pass
93
94    if n > 6:
95        raise Exception("Install Python module 'munkres' or 'scipy >= 0.17.0'")
96
97    # Otherwise just brute-force
98    permutations = itertools.permutations(range(n))
99    best = list(next(permutations))
100    best_cost = _matching_cost(G, best)
101    for p in permutations:
102        cost = _matching_cost(G, p)
103        if cost < best_cost:
104            best, best_cost = list(p), cost
105    return best, best_cost
106
107
108def test(glyphsets, glyphs=None, names=None):
109
110    if names is None:
111        names = glyphsets
112    if glyphs is None:
113        glyphs = glyphsets[0].keys()
114
115    hist = []
116    problems = OrderedDict()
117
118    def add_problem(glyphname, problem):
119        problems.setdefault(glyphname, []).append(problem)
120
121    for glyph_name in glyphs:
122        # print()
123        # print(glyph_name)
124
125        try:
126            allVectors = []
127            allNodeTypes = []
128            for glyphset, name in zip(glyphsets, names):
129                # print('.', end='')
130                if glyph_name not in glyphset:
131                    add_problem(glyph_name, {"type": "missing", "master": name})
132                    continue
133                glyph = glyphset[glyph_name]
134
135                perContourPen = PerContourOrComponentPen(
136                    RecordingPen, glyphset=glyphset
137                )
138                glyph.draw(perContourPen)
139                contourPens = perContourPen.value
140                del perContourPen
141
142                contourVectors = []
143                nodeTypes = []
144                allNodeTypes.append(nodeTypes)
145                allVectors.append(contourVectors)
146                for ix, contour in enumerate(contourPens):
147                    nodeTypes.append(
148                        tuple(instruction[0] for instruction in contour.value)
149                    )
150                    stats = StatisticsPen(glyphset=glyphset)
151                    try:
152                        contour.replay(stats)
153                    except OpenContourError as e:
154                        add_problem(
155                            glyph_name,
156                            {"master": name, "contour": ix, "type": "open_path"},
157                        )
158                        continue
159                    size = abs(stats.area) ** 0.5 * 0.5
160                    vector = (
161                        int(size),
162                        int(stats.meanX),
163                        int(stats.meanY),
164                        int(stats.stddevX * 2),
165                        int(stats.stddevY * 2),
166                        int(stats.correlation * size),
167                    )
168                    contourVectors.append(vector)
169                    # print(vector)
170
171            # Check each master against the next one in the list.
172            for i, (m0, m1) in enumerate(zip(allNodeTypes[:-1], allNodeTypes[1:])):
173                if len(m0) != len(m1):
174                    add_problem(
175                        glyph_name,
176                        {
177                            "type": "path_count",
178                            "master_1": names[i],
179                            "master_2": names[i + 1],
180                            "value_1": len(m0),
181                            "value_2": len(m1),
182                        },
183                    )
184                if m0 == m1:
185                    continue
186                for pathIx, (nodes1, nodes2) in enumerate(zip(m0, m1)):
187                    if nodes1 == nodes2:
188                        continue
189                    if len(nodes1) != len(nodes2):
190                        add_problem(
191                            glyph_name,
192                            {
193                                "type": "node_count",
194                                "path": pathIx,
195                                "master_1": names[i],
196                                "master_2": names[i + 1],
197                                "value_1": len(nodes1),
198                                "value_2": len(nodes2),
199                            },
200                        )
201                        continue
202                    for nodeIx, (n1, n2) in enumerate(zip(nodes1, nodes2)):
203                        if n1 != n2:
204                            add_problem(
205                                glyph_name,
206                                {
207                                    "type": "node_incompatibility",
208                                    "path": pathIx,
209                                    "node": nodeIx,
210                                    "master_1": names[i],
211                                    "master_2": names[i + 1],
212                                    "value_1": n1,
213                                    "value_2": n2,
214                                },
215                            )
216                            continue
217
218            for i, (m0, m1) in enumerate(zip(allVectors[:-1], allVectors[1:])):
219                if len(m0) != len(m1):
220                    # We already reported this
221                    continue
222                if not m0:
223                    continue
224                costs = [[_vlen(_vdiff(v0, v1)) for v1 in m1] for v0 in m0]
225                matching, matching_cost = min_cost_perfect_bipartite_matching(costs)
226                if matching != list(range(len(m0))):
227                    add_problem(
228                        glyph_name,
229                        {
230                            "type": "contour_order",
231                            "master_1": names[i],
232                            "master_2": names[i + 1],
233                            "value_1": list(range(len(m0))),
234                            "value_2": matching,
235                        },
236                    )
237                    break
238                upem = 2048
239                item_cost = round(
240                    (matching_cost / len(m0) / len(m0[0])) ** 0.5 / upem * 100
241                )
242                hist.append(item_cost)
243                threshold = 7
244                if item_cost >= threshold:
245                    add_problem(
246                        glyph_name,
247                        {
248                            "type": "high_cost",
249                            "master_1": names[i],
250                            "master_2": names[i + 1],
251                            "value_1": item_cost,
252                            "value_2": threshold,
253                        },
254                    )
255
256        except ValueError as e:
257            add_problem(
258                glyph_name,
259                {"type": "math_error", "master": name, "error": e},
260            )
261    return problems
262
263
264def main(args=None):
265    """Test for interpolatability issues between fonts"""
266    import argparse
267
268    parser = argparse.ArgumentParser(
269        "fonttools varLib.interpolatable",
270        description=main.__doc__,
271    )
272    parser.add_argument(
273        "--json",
274        action="store_true",
275        help="Output report in JSON format",
276    )
277    parser.add_argument(
278        "inputs", metavar="FILE", type=str, nargs="+", help="Input TTF/UFO files"
279    )
280
281    args = parser.parse_args(args)
282    glyphs = None
283    # glyphs = ['uni08DB', 'uniFD76']
284    # glyphs = ['uni08DE', 'uni0034']
285    # glyphs = ['uni08DE', 'uni0034', 'uni0751', 'uni0753', 'uni0754', 'uni08A4', 'uni08A4.fina', 'uni08A5.fina']
286
287    from os.path import basename
288
289    names = [basename(filename).rsplit(".", 1)[0] for filename in args.inputs]
290
291    fonts = []
292    for filename in args.inputs:
293        if filename.endswith(".ufo"):
294            from fontTools.ufoLib import UFOReader
295
296            fonts.append(UFOReader(filename))
297        else:
298            from fontTools.ttLib import TTFont
299
300            fonts.append(TTFont(filename))
301
302    glyphsets = [font.getGlyphSet() for font in fonts]
303    problems = test(glyphsets, glyphs=glyphs, names=names)
304    if args.json:
305        import json
306
307        print(json.dumps(problems))
308    else:
309        for glyph, glyph_problems in problems.items():
310            print(f"Glyph {glyph} was not compatible: ")
311            for p in glyph_problems:
312                if p["type"] == "missing":
313                    print("    Glyph was missing in master %s" % p["master"])
314                if p["type"] == "open_path":
315                    print("    Glyph has an open path in master %s" % p["master"])
316                if p["type"] == "path_count":
317                    print(
318                        "    Path count differs: %i in %s, %i in %s"
319                        % (p["value_1"], p["master_1"], p["value_2"], p["master_2"])
320                    )
321                if p["type"] == "node_count":
322                    print(
323                        "    Node count differs in path %i: %i in %s, %i in %s"
324                        % (
325                            p["path"],
326                            p["value_1"],
327                            p["master_1"],
328                            p["value_2"],
329                            p["master_2"],
330                        )
331                    )
332                if p["type"] == "node_incompatibility":
333                    print(
334                        "    Node %o incompatible in path %i: %s in %s, %s in %s"
335                        % (
336                            p["node"],
337                            p["path"],
338                            p["value_1"],
339                            p["master_1"],
340                            p["value_2"],
341                            p["master_2"],
342                        )
343                    )
344                if p["type"] == "contour_order":
345                    print(
346                        "    Contour order differs: %s in %s, %s in %s"
347                        % (
348                            p["value_1"],
349                            p["master_1"],
350                            p["value_2"],
351                            p["master_2"],
352                        )
353                    )
354                if p["type"] == "high_cost":
355                    print(
356                        "    Interpolation has high cost: cost of %s to %s = %i, threshold %i"
357                        % (
358                            p["master_1"],
359                            p["master_2"],
360                            p["value_1"],
361                            p["value_2"],
362                        )
363                    )
364    if problems:
365        return problems
366
367
368if __name__ == "__main__":
369    import sys
370
371    problems = main()
372    sys.exit(int(bool(problems)))
373