Spaces:
Running
Running
"""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") | |