Spaces:
Paused
Paused
| """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_]+)" # char name | |
| r"\s+" | |
| r"(\d+)" # number of parts | |
| r"\s*;\s*" | |
| ) | |
| 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") | |