| """Module for reading and writing AFM (Adobe Font Metrics) files. |
| |
| Note that this has been designed to read in AFM files generated by Fontographer |
| and has not been tested on many other files. In particular, it does not |
| implement the whole Adobe AFM specification [#f1]_ but, it should read most |
| "common" AFM files. |
| |
| Here is an example of using `afmLib` to read, modify and write an AFM file: |
| |
| >>> from fontTools.afmLib import AFM |
| >>> f = AFM("Tests/afmLib/data/TestAFM.afm") |
| >>> |
| >>> # Accessing a pair gets you the kern value |
| >>> f[("V","A")] |
| -60 |
| >>> |
| >>> # Accessing a glyph name gets you metrics |
| >>> f["A"] |
| (65, 668, (8, -25, 660, 666)) |
| >>> # (charnum, width, bounding box) |
| >>> |
| >>> # Accessing an attribute gets you metadata |
| >>> f.FontName |
| 'TestFont-Regular' |
| >>> f.FamilyName |
| 'TestFont' |
| >>> f.Weight |
| 'Regular' |
| >>> f.XHeight |
| 500 |
| >>> f.Ascender |
| 750 |
| >>> |
| >>> # Attributes and items can also be set |
| >>> f[("A","V")] = -150 # Tighten kerning |
| >>> f.FontName = "TestFont Squished" |
| >>> |
| >>> # And the font written out again (remove the # in front) |
| >>> #f.write("testfont-squished.afm") |
| |
| .. rubric:: Footnotes |
| |
| .. [#f1] `Adobe Technote 5004 <https://www.adobe.com/content/dam/acom/en/devnet/font/pdfs/5004.AFM_Spec.pdf>`_, |
| Adobe Font Metrics File Format Specification. |
| |
| """ |
| |
| |
| import re |
| |
| # every single line starts with a "word" |
| identifierRE = re.compile(r"^([A-Za-z]+).*") |
| |
| # regular expression to parse char lines |
| charRE = re.compile( |
| r"(-?\d+)" # charnum |
| r"\s*;\s*WX\s+" # ; WX |
| r"(-?\d+)" # width |
| r"\s*;\s*N\s+" # ; N |
| r"([.A-Za-z0-9_]+)" # charname |
| r"\s*;\s*B\s+" # ; B |
| r"(-?\d+)" # left |
| r"\s+" |
| r"(-?\d+)" # bottom |
| r"\s+" |
| r"(-?\d+)" # right |
| r"\s+" |
| r"(-?\d+)" # top |
| r"\s*;\s*" # ; |
| ) |
| |
| # regular expression to parse kerning lines |
| kernRE = re.compile( |
| r"([.A-Za-z0-9_]+)" # leftchar |
| r"\s+" |
| r"([.A-Za-z0-9_]+)" # rightchar |
| r"\s+" |
| r"(-?\d+)" # value |
| r"\s*" |
| ) |
| |
| # regular expressions to parse composite info lines of the form: |
| # Aacute 2 ; PCC A 0 0 ; PCC acute 182 211 ; |
| compositeRE = re.compile( |
| r"([.A-Za-z0-9_]+)" r"\s+" r"(\d+)" r"\s*;\s*" # char name # number of parts |
| ) |
| componentRE = re.compile( |
| r"PCC\s+" # PPC |
| r"([.A-Za-z0-9_]+)" # base char name |
| r"\s+" |
| r"(-?\d+)" # x offset |
| r"\s+" |
| r"(-?\d+)" # y offset |
| r"\s*;\s*" |
| ) |
| |
| preferredAttributeOrder = [ |
| "FontName", |
| "FullName", |
| "FamilyName", |
| "Weight", |
| "ItalicAngle", |
| "IsFixedPitch", |
| "FontBBox", |
| "UnderlinePosition", |
| "UnderlineThickness", |
| "Version", |
| "Notice", |
| "EncodingScheme", |
| "CapHeight", |
| "XHeight", |
| "Ascender", |
| "Descender", |
| ] |
| |
| |
| class error(Exception): |
| pass |
| |
| |
| class AFM(object): |
| _attrs = None |
| |
| _keywords = [ |
| "StartFontMetrics", |
| "EndFontMetrics", |
| "StartCharMetrics", |
| "EndCharMetrics", |
| "StartKernData", |
| "StartKernPairs", |
| "EndKernPairs", |
| "EndKernData", |
| "StartComposites", |
| "EndComposites", |
| ] |
| |
| def __init__(self, path=None): |
| """AFM file reader. |
| |
| Instantiating an object with a path name will cause the file to be opened, |
| read, and parsed. Alternatively the path can be left unspecified, and a |
| file can be parsed later with the :meth:`read` method.""" |
| self._attrs = {} |
| self._chars = {} |
| self._kerning = {} |
| self._index = {} |
| self._comments = [] |
| self._composites = {} |
| if path is not None: |
| self.read(path) |
| |
| def read(self, path): |
| """Opens, reads and parses a file.""" |
| lines = readlines(path) |
| for line in lines: |
| if not line.strip(): |
| continue |
| m = identifierRE.match(line) |
| if m is None: |
| raise error("syntax error in AFM file: " + repr(line)) |
| |
| pos = m.regs[1][1] |
| word = line[:pos] |
| rest = line[pos:].strip() |
| if word in self._keywords: |
| continue |
| if word == "C": |
| self.parsechar(rest) |
| elif word == "KPX": |
| self.parsekernpair(rest) |
| elif word == "CC": |
| self.parsecomposite(rest) |
| else: |
| self.parseattr(word, rest) |
| |
| def parsechar(self, rest): |
| m = charRE.match(rest) |
| if m is None: |
| raise error("syntax error in AFM file: " + repr(rest)) |
| things = [] |
| for fr, to in m.regs[1:]: |
| things.append(rest[fr:to]) |
| charname = things[2] |
| del things[2] |
| charnum, width, l, b, r, t = (int(thing) for thing in things) |
| self._chars[charname] = charnum, width, (l, b, r, t) |
| |
| def parsekernpair(self, rest): |
| m = kernRE.match(rest) |
| if m is None: |
| raise error("syntax error in AFM file: " + repr(rest)) |
| things = [] |
| for fr, to in m.regs[1:]: |
| things.append(rest[fr:to]) |
| leftchar, rightchar, value = things |
| value = int(value) |
| self._kerning[(leftchar, rightchar)] = value |
| |
| def parseattr(self, word, rest): |
| if word == "FontBBox": |
| l, b, r, t = [int(thing) for thing in rest.split()] |
| self._attrs[word] = l, b, r, t |
| elif word == "Comment": |
| self._comments.append(rest) |
| else: |
| try: |
| value = int(rest) |
| except (ValueError, OverflowError): |
| self._attrs[word] = rest |
| else: |
| self._attrs[word] = value |
| |
| def parsecomposite(self, rest): |
| m = compositeRE.match(rest) |
| if m is None: |
| raise error("syntax error in AFM file: " + repr(rest)) |
| charname = m.group(1) |
| ncomponents = int(m.group(2)) |
| rest = rest[m.regs[0][1] :] |
| components = [] |
| while True: |
| m = componentRE.match(rest) |
| if m is None: |
| raise error("syntax error in AFM file: " + repr(rest)) |
| basechar = m.group(1) |
| xoffset = int(m.group(2)) |
| yoffset = int(m.group(3)) |
| components.append((basechar, xoffset, yoffset)) |
| rest = rest[m.regs[0][1] :] |
| if not rest: |
| break |
| assert len(components) == ncomponents |
| self._composites[charname] = components |
| |
| def write(self, path, sep="\r"): |
| """Writes out an AFM font to the given path.""" |
| import time |
| |
| lines = [ |
| "StartFontMetrics 2.0", |
| "Comment Generated by afmLib; at %s" |
| % (time.strftime("%m/%d/%Y %H:%M:%S", time.localtime(time.time()))), |
| ] |
| |
| # write comments, assuming (possibly wrongly!) they should |
| # all appear at the top |
| for comment in self._comments: |
| lines.append("Comment " + comment) |
| |
| # write attributes, first the ones we know about, in |
| # a preferred order |
| attrs = self._attrs |
| for attr in preferredAttributeOrder: |
| if attr in attrs: |
| value = attrs[attr] |
| if attr == "FontBBox": |
| value = "%s %s %s %s" % value |
| lines.append(attr + " " + str(value)) |
| # then write the attributes we don't know about, |
| # in alphabetical order |
| items = sorted(attrs.items()) |
| for attr, value in items: |
| if attr in preferredAttributeOrder: |
| continue |
| lines.append(attr + " " + str(value)) |
| |
| # write char metrics |
| lines.append("StartCharMetrics " + repr(len(self._chars))) |
| items = [ |
| (charnum, (charname, width, box)) |
| for charname, (charnum, width, box) in self._chars.items() |
| ] |
| |
| def myKey(a): |
| """Custom key function to make sure unencoded chars (-1) |
| end up at the end of the list after sorting.""" |
| if a[0] == -1: |
| a = (0xFFFF,) + a[1:] # 0xffff is an arbitrary large number |
| return a |
| |
| items.sort(key=myKey) |
| |
| for charnum, (charname, width, (l, b, r, t)) in items: |
| lines.append( |
| "C %d ; WX %d ; N %s ; B %d %d %d %d ;" |
| % (charnum, width, charname, l, b, r, t) |
| ) |
| lines.append("EndCharMetrics") |
| |
| # write kerning info |
| lines.append("StartKernData") |
| lines.append("StartKernPairs " + repr(len(self._kerning))) |
| items = sorted(self._kerning.items()) |
| for (leftchar, rightchar), value in items: |
| lines.append("KPX %s %s %d" % (leftchar, rightchar, value)) |
| lines.append("EndKernPairs") |
| lines.append("EndKernData") |
| |
| if self._composites: |
| composites = sorted(self._composites.items()) |
| lines.append("StartComposites %s" % len(self._composites)) |
| for charname, components in composites: |
| line = "CC %s %s ;" % (charname, len(components)) |
| for basechar, xoffset, yoffset in components: |
| line = line + " PCC %s %s %s ;" % (basechar, xoffset, yoffset) |
| lines.append(line) |
| lines.append("EndComposites") |
| |
| lines.append("EndFontMetrics") |
| |
| writelines(path, lines, sep) |
| |
| def has_kernpair(self, pair): |
| """Returns `True` if the given glyph pair (specified as a tuple) exists |
| in the kerning dictionary.""" |
| return pair in self._kerning |
| |
| def kernpairs(self): |
| """Returns a list of all kern pairs in the kerning dictionary.""" |
| return list(self._kerning.keys()) |
| |
| def has_char(self, char): |
| """Returns `True` if the given glyph exists in the font.""" |
| return char in self._chars |
| |
| def chars(self): |
| """Returns a list of all glyph names in the font.""" |
| return list(self._chars.keys()) |
| |
| def comments(self): |
| """Returns all comments from the file.""" |
| return self._comments |
| |
| def addComment(self, comment): |
| """Adds a new comment to the file.""" |
| self._comments.append(comment) |
| |
| def addComposite(self, glyphName, components): |
| """Specifies that the glyph `glyphName` is made up of the given components. |
| The components list should be of the following form:: |
| |
| [ |
| (glyphname, xOffset, yOffset), |
| ... |
| ] |
| |
| """ |
| self._composites[glyphName] = components |
| |
| def __getattr__(self, attr): |
| if attr in self._attrs: |
| return self._attrs[attr] |
| else: |
| raise AttributeError(attr) |
| |
| def __setattr__(self, attr, value): |
| # all attrs *not* starting with "_" are consider to be AFM keywords |
| if attr[:1] == "_": |
| self.__dict__[attr] = value |
| else: |
| self._attrs[attr] = value |
| |
| def __delattr__(self, attr): |
| # all attrs *not* starting with "_" are consider to be AFM keywords |
| if attr[:1] == "_": |
| try: |
| del self.__dict__[attr] |
| except KeyError: |
| raise AttributeError(attr) |
| else: |
| try: |
| del self._attrs[attr] |
| except KeyError: |
| raise AttributeError(attr) |
| |
| def __getitem__(self, key): |
| if isinstance(key, tuple): |
| # key is a tuple, return the kernpair |
| return self._kerning[key] |
| else: |
| # return the metrics instead |
| return self._chars[key] |
| |
| def __setitem__(self, key, value): |
| if isinstance(key, tuple): |
| # key is a tuple, set kernpair |
| self._kerning[key] = value |
| else: |
| # set char metrics |
| self._chars[key] = value |
| |
| def __delitem__(self, key): |
| if isinstance(key, tuple): |
| # key is a tuple, del kernpair |
| del self._kerning[key] |
| else: |
| # del char metrics |
| del self._chars[key] |
| |
| def __repr__(self): |
| if hasattr(self, "FullName"): |
| return "<AFM object for %s>" % self.FullName |
| else: |
| return "<AFM object at %x>" % id(self) |
| |
| |
| def readlines(path): |
| with open(path, "r", encoding="ascii") as f: |
| data = f.read() |
| return data.splitlines() |
| |
| |
| def writelines(path, lines, sep="\r"): |
| with open(path, "w", encoding="ascii", newline=sep) as f: |
| f.write("\n".join(lines) + "\n") |
| |
| |
| if __name__ == "__main__": |
| import EasyDialogs |
| |
| path = EasyDialogs.AskFileForOpen() |
| if path: |
| afm = AFM(path) |
| char = "A" |
| if afm.has_char(char): |
| print(afm[char]) # print charnum, width and boundingbox |
| pair = ("A", "V") |
| if afm.has_kernpair(pair): |
| print(afm[pair]) # print kerning value for pair |
| print(afm.Version) # various other afm entries have become attributes |
| print(afm.Weight) |
| # afm.comments() returns a list of all Comment lines found in the AFM |
| print(afm.comments()) |
| # print afm.chars() |
| # print afm.kernpairs() |
| print(afm) |
| afm.write(path + ".muck") |