123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440 |
- """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")
|