xref: /aosp_15_r20/external/fonttools/Lib/fontTools/afmLib.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1"""Module for reading and writing AFM (Adobe Font Metrics) files.
2
3Note that this has been designed to read in AFM files generated by Fontographer
4and has not been tested on many other files. In particular, it does not
5implement the whole Adobe AFM specification [#f1]_ but, it should read most
6"common" AFM files.
7
8Here is an example of using `afmLib` to read, modify and write an AFM file:
9
10	>>> from fontTools.afmLib import AFM
11	>>> f = AFM("Tests/afmLib/data/TestAFM.afm")
12	>>>
13	>>> # Accessing a pair gets you the kern value
14	>>> f[("V","A")]
15	-60
16	>>>
17	>>> # Accessing a glyph name gets you metrics
18	>>> f["A"]
19	(65, 668, (8, -25, 660, 666))
20	>>> # (charnum, width, bounding box)
21	>>>
22	>>> # Accessing an attribute gets you metadata
23	>>> f.FontName
24	'TestFont-Regular'
25	>>> f.FamilyName
26	'TestFont'
27	>>> f.Weight
28	'Regular'
29	>>> f.XHeight
30	500
31	>>> f.Ascender
32	750
33	>>>
34	>>> # Attributes and items can also be set
35	>>> f[("A","V")] = -150 # Tighten kerning
36	>>> f.FontName = "TestFont Squished"
37	>>>
38	>>> # And the font written out again (remove the # in front)
39	>>> #f.write("testfont-squished.afm")
40
41.. rubric:: Footnotes
42
43.. [#f1] `Adobe Technote 5004 <https://www.adobe.com/content/dam/acom/en/devnet/font/pdfs/5004.AFM_Spec.pdf>`_,
44   Adobe Font Metrics File Format Specification.
45
46"""
47
48import re
49
50# every single line starts with a "word"
51identifierRE = re.compile(r"^([A-Za-z]+).*")
52
53# regular expression to parse char lines
54charRE = re.compile(
55    r"(-?\d+)"  # charnum
56    r"\s*;\s*WX\s+"  # ; WX
57    r"(-?\d+)"  # width
58    r"\s*;\s*N\s+"  # ; N
59    r"([.A-Za-z0-9_]+)"  # charname
60    r"\s*;\s*B\s+"  # ; B
61    r"(-?\d+)"  # left
62    r"\s+"
63    r"(-?\d+)"  # bottom
64    r"\s+"
65    r"(-?\d+)"  # right
66    r"\s+"
67    r"(-?\d+)"  # top
68    r"\s*;\s*"  # ;
69)
70
71# regular expression to parse kerning lines
72kernRE = re.compile(
73    r"([.A-Za-z0-9_]+)"  # leftchar
74    r"\s+"
75    r"([.A-Za-z0-9_]+)"  # rightchar
76    r"\s+"
77    r"(-?\d+)"  # value
78    r"\s*"
79)
80
81# regular expressions to parse composite info lines of the form:
82# Aacute 2 ; PCC A 0 0 ; PCC acute 182 211 ;
83compositeRE = re.compile(
84    r"([.A-Za-z0-9_]+)"  # char name
85    r"\s+"
86    r"(\d+)"  # number of parts
87    r"\s*;\s*"
88)
89componentRE = re.compile(
90    r"PCC\s+"  # PPC
91    r"([.A-Za-z0-9_]+)"  # base char name
92    r"\s+"
93    r"(-?\d+)"  # x offset
94    r"\s+"
95    r"(-?\d+)"  # y offset
96    r"\s*;\s*"
97)
98
99preferredAttributeOrder = [
100    "FontName",
101    "FullName",
102    "FamilyName",
103    "Weight",
104    "ItalicAngle",
105    "IsFixedPitch",
106    "FontBBox",
107    "UnderlinePosition",
108    "UnderlineThickness",
109    "Version",
110    "Notice",
111    "EncodingScheme",
112    "CapHeight",
113    "XHeight",
114    "Ascender",
115    "Descender",
116]
117
118
119class error(Exception):
120    pass
121
122
123class AFM(object):
124    _attrs = None
125
126    _keywords = [
127        "StartFontMetrics",
128        "EndFontMetrics",
129        "StartCharMetrics",
130        "EndCharMetrics",
131        "StartKernData",
132        "StartKernPairs",
133        "EndKernPairs",
134        "EndKernData",
135        "StartComposites",
136        "EndComposites",
137    ]
138
139    def __init__(self, path=None):
140        """AFM file reader.
141
142        Instantiating an object with a path name will cause the file to be opened,
143        read, and parsed. Alternatively the path can be left unspecified, and a
144        file can be parsed later with the :meth:`read` method."""
145        self._attrs = {}
146        self._chars = {}
147        self._kerning = {}
148        self._index = {}
149        self._comments = []
150        self._composites = {}
151        if path is not None:
152            self.read(path)
153
154    def read(self, path):
155        """Opens, reads and parses a file."""
156        lines = readlines(path)
157        for line in lines:
158            if not line.strip():
159                continue
160            m = identifierRE.match(line)
161            if m is None:
162                raise error("syntax error in AFM file: " + repr(line))
163
164            pos = m.regs[1][1]
165            word = line[:pos]
166            rest = line[pos:].strip()
167            if word in self._keywords:
168                continue
169            if word == "C":
170                self.parsechar(rest)
171            elif word == "KPX":
172                self.parsekernpair(rest)
173            elif word == "CC":
174                self.parsecomposite(rest)
175            else:
176                self.parseattr(word, rest)
177
178    def parsechar(self, rest):
179        m = charRE.match(rest)
180        if m is None:
181            raise error("syntax error in AFM file: " + repr(rest))
182        things = []
183        for fr, to in m.regs[1:]:
184            things.append(rest[fr:to])
185        charname = things[2]
186        del things[2]
187        charnum, width, l, b, r, t = (int(thing) for thing in things)
188        self._chars[charname] = charnum, width, (l, b, r, t)
189
190    def parsekernpair(self, rest):
191        m = kernRE.match(rest)
192        if m is None:
193            raise error("syntax error in AFM file: " + repr(rest))
194        things = []
195        for fr, to in m.regs[1:]:
196            things.append(rest[fr:to])
197        leftchar, rightchar, value = things
198        value = int(value)
199        self._kerning[(leftchar, rightchar)] = value
200
201    def parseattr(self, word, rest):
202        if word == "FontBBox":
203            l, b, r, t = [int(thing) for thing in rest.split()]
204            self._attrs[word] = l, b, r, t
205        elif word == "Comment":
206            self._comments.append(rest)
207        else:
208            try:
209                value = int(rest)
210            except (ValueError, OverflowError):
211                self._attrs[word] = rest
212            else:
213                self._attrs[word] = value
214
215    def parsecomposite(self, rest):
216        m = compositeRE.match(rest)
217        if m is None:
218            raise error("syntax error in AFM file: " + repr(rest))
219        charname = m.group(1)
220        ncomponents = int(m.group(2))
221        rest = rest[m.regs[0][1] :]
222        components = []
223        while True:
224            m = componentRE.match(rest)
225            if m is None:
226                raise error("syntax error in AFM file: " + repr(rest))
227            basechar = m.group(1)
228            xoffset = int(m.group(2))
229            yoffset = int(m.group(3))
230            components.append((basechar, xoffset, yoffset))
231            rest = rest[m.regs[0][1] :]
232            if not rest:
233                break
234        assert len(components) == ncomponents
235        self._composites[charname] = components
236
237    def write(self, path, sep="\r"):
238        """Writes out an AFM font to the given path."""
239        import time
240
241        lines = [
242            "StartFontMetrics 2.0",
243            "Comment Generated by afmLib; at %s"
244            % (time.strftime("%m/%d/%Y %H:%M:%S", time.localtime(time.time()))),
245        ]
246
247        # write comments, assuming (possibly wrongly!) they should
248        # all appear at the top
249        for comment in self._comments:
250            lines.append("Comment " + comment)
251
252        # write attributes, first the ones we know about, in
253        # a preferred order
254        attrs = self._attrs
255        for attr in preferredAttributeOrder:
256            if attr in attrs:
257                value = attrs[attr]
258                if attr == "FontBBox":
259                    value = "%s %s %s %s" % value
260                lines.append(attr + " " + str(value))
261        # then write the attributes we don't know about,
262        # in alphabetical order
263        items = sorted(attrs.items())
264        for attr, value in items:
265            if attr in preferredAttributeOrder:
266                continue
267            lines.append(attr + " " + str(value))
268
269        # write char metrics
270        lines.append("StartCharMetrics " + repr(len(self._chars)))
271        items = [
272            (charnum, (charname, width, box))
273            for charname, (charnum, width, box) in self._chars.items()
274        ]
275
276        def myKey(a):
277            """Custom key function to make sure unencoded chars (-1)
278            end up at the end of the list after sorting."""
279            if a[0] == -1:
280                a = (0xFFFF,) + a[1:]  # 0xffff is an arbitrary large number
281            return a
282
283        items.sort(key=myKey)
284
285        for charnum, (charname, width, (l, b, r, t)) in items:
286            lines.append(
287                "C %d ; WX %d ; N %s ; B %d %d %d %d ;"
288                % (charnum, width, charname, l, b, r, t)
289            )
290        lines.append("EndCharMetrics")
291
292        # write kerning info
293        lines.append("StartKernData")
294        lines.append("StartKernPairs " + repr(len(self._kerning)))
295        items = sorted(self._kerning.items())
296        for (leftchar, rightchar), value in items:
297            lines.append("KPX %s %s %d" % (leftchar, rightchar, value))
298        lines.append("EndKernPairs")
299        lines.append("EndKernData")
300
301        if self._composites:
302            composites = sorted(self._composites.items())
303            lines.append("StartComposites %s" % len(self._composites))
304            for charname, components in composites:
305                line = "CC %s %s ;" % (charname, len(components))
306                for basechar, xoffset, yoffset in components:
307                    line = line + " PCC %s %s %s ;" % (basechar, xoffset, yoffset)
308                lines.append(line)
309            lines.append("EndComposites")
310
311        lines.append("EndFontMetrics")
312
313        writelines(path, lines, sep)
314
315    def has_kernpair(self, pair):
316        """Returns `True` if the given glyph pair (specified as a tuple) exists
317        in the kerning dictionary."""
318        return pair in self._kerning
319
320    def kernpairs(self):
321        """Returns a list of all kern pairs in the kerning dictionary."""
322        return list(self._kerning.keys())
323
324    def has_char(self, char):
325        """Returns `True` if the given glyph exists in the font."""
326        return char in self._chars
327
328    def chars(self):
329        """Returns a list of all glyph names in the font."""
330        return list(self._chars.keys())
331
332    def comments(self):
333        """Returns all comments from the file."""
334        return self._comments
335
336    def addComment(self, comment):
337        """Adds a new comment to the file."""
338        self._comments.append(comment)
339
340    def addComposite(self, glyphName, components):
341        """Specifies that the glyph `glyphName` is made up of the given components.
342        The components list should be of the following form::
343
344                [
345                        (glyphname, xOffset, yOffset),
346                        ...
347                ]
348
349        """
350        self._composites[glyphName] = components
351
352    def __getattr__(self, attr):
353        if attr in self._attrs:
354            return self._attrs[attr]
355        else:
356            raise AttributeError(attr)
357
358    def __setattr__(self, attr, value):
359        # all attrs *not* starting with "_" are consider to be AFM keywords
360        if attr[:1] == "_":
361            self.__dict__[attr] = value
362        else:
363            self._attrs[attr] = value
364
365    def __delattr__(self, attr):
366        # all attrs *not* starting with "_" are consider to be AFM keywords
367        if attr[:1] == "_":
368            try:
369                del self.__dict__[attr]
370            except KeyError:
371                raise AttributeError(attr)
372        else:
373            try:
374                del self._attrs[attr]
375            except KeyError:
376                raise AttributeError(attr)
377
378    def __getitem__(self, key):
379        if isinstance(key, tuple):
380            # key is a tuple, return the kernpair
381            return self._kerning[key]
382        else:
383            # return the metrics instead
384            return self._chars[key]
385
386    def __setitem__(self, key, value):
387        if isinstance(key, tuple):
388            # key is a tuple, set kernpair
389            self._kerning[key] = value
390        else:
391            # set char metrics
392            self._chars[key] = value
393
394    def __delitem__(self, key):
395        if isinstance(key, tuple):
396            # key is a tuple, del kernpair
397            del self._kerning[key]
398        else:
399            # del char metrics
400            del self._chars[key]
401
402    def __repr__(self):
403        if hasattr(self, "FullName"):
404            return "<AFM object for %s>" % self.FullName
405        else:
406            return "<AFM object at %x>" % id(self)
407
408
409def readlines(path):
410    with open(path, "r", encoding="ascii") as f:
411        data = f.read()
412    return data.splitlines()
413
414
415def writelines(path, lines, sep="\r"):
416    with open(path, "w", encoding="ascii", newline=sep) as f:
417        f.write("\n".join(lines) + "\n")
418
419
420if __name__ == "__main__":
421    import EasyDialogs
422
423    path = EasyDialogs.AskFileForOpen()
424    if path:
425        afm = AFM(path)
426        char = "A"
427        if afm.has_char(char):
428            print(afm[char])  # print charnum, width and boundingbox
429        pair = ("A", "V")
430        if afm.has_kernpair(pair):
431            print(afm[pair])  # print kerning value for pair
432        print(afm.Version)  # various other afm entries have become attributes
433        print(afm.Weight)
434        # afm.comments() returns a list of all Comment lines found in the AFM
435        print(afm.comments())
436        # print afm.chars()
437        # print afm.kernpairs()
438        print(afm)
439        afm.write(path + ".muck")
440