|
"""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 |
|
|
|
|
|
identifierRE = re.compile(r"^([A-Za-z]+).*") |
|
|
|
|
|
charRE = re.compile( |
|
r"(-?\d+)" |
|
r"\s*;\s*WX\s+" |
|
r"(-?\d+)" |
|
r"\s*;\s*N\s+" |
|
r"([.A-Za-z0-9_]+)" |
|
r"\s*;\s*B\s+" |
|
r"(-?\d+)" |
|
r"\s+" |
|
r"(-?\d+)" |
|
r"\s+" |
|
r"(-?\d+)" |
|
r"\s+" |
|
r"(-?\d+)" |
|
r"\s*;\s*" |
|
) |
|
|
|
|
|
kernRE = re.compile( |
|
r"([.A-Za-z0-9_]+)" |
|
r"\s+" |
|
r"([.A-Za-z0-9_]+)" |
|
r"\s+" |
|
r"(-?\d+)" |
|
r"\s*" |
|
) |
|
|
|
|
|
|
|
compositeRE = re.compile( |
|
r"([.A-Za-z0-9_]+)" |
|
r"\s+" |
|
r"(\d+)" |
|
r"\s*;\s*" |
|
) |
|
componentRE = re.compile( |
|
r"PCC\s+" |
|
r"([.A-Za-z0-9_]+)" |
|
r"\s+" |
|
r"(-?\d+)" |
|
r"\s+" |
|
r"(-?\d+)" |
|
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()))), |
|
] |
|
|
|
|
|
|
|
for comment in self._comments: |
|
lines.append("Comment " + comment) |
|
|
|
|
|
|
|
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)) |
|
|
|
|
|
items = sorted(attrs.items()) |
|
for attr, value in items: |
|
if attr in preferredAttributeOrder: |
|
continue |
|
lines.append(attr + " " + str(value)) |
|
|
|
|
|
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:] |
|
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") |
|
|
|
|
|
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): |
|
|
|
if attr[:1] == "_": |
|
self.__dict__[attr] = value |
|
else: |
|
self._attrs[attr] = value |
|
|
|
def __delattr__(self, attr): |
|
|
|
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): |
|
|
|
return self._kerning[key] |
|
else: |
|
|
|
return self._chars[key] |
|
|
|
def __setitem__(self, key, value): |
|
if isinstance(key, tuple): |
|
|
|
self._kerning[key] = value |
|
else: |
|
|
|
self._chars[key] = value |
|
|
|
def __delitem__(self, key): |
|
if isinstance(key, tuple): |
|
|
|
del self._kerning[key] |
|
else: |
|
|
|
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]) |
|
pair = ("A", "V") |
|
if afm.has_kernpair(pair): |
|
print(afm[pair]) |
|
print(afm.Version) |
|
print(afm.Weight) |
|
|
|
print(afm.comments()) |
|
|
|
|
|
print(afm) |
|
afm.write(path + ".muck") |
|
|