1"""Module for reading and writing AFM (Adobe Font Metrics) files.
2
3Note that this has been designed to read in AFM files generated by Fontographer
4and has not been tested on many other files. In particular, it does not
5implement the whole Adobe AFM specification [#f1]_ but, it should read most
6"common" AFM files.
7
8Here is an example of using `afmLib` to read, modify and write an AFM file:
9
10	>>> from fontTools.afmLib import AFM
11	>>> f = AFM("Tests/afmLib/data/TestAFM.afm")
12	>>>
13	>>> # Accessing a pair gets you the kern value
14	>>> f[("V","A")]
15	-60
16	>>>
17	>>> # Accessing a glyph name gets you metrics
18	>>> f["A"]
19	(65, 668, (8, -25, 660, 666))
20	>>> # (charnum, width, bounding box)
21	>>>
22	>>> # Accessing an attribute gets you metadata
23	>>> f.FontName
24	'TestFont-Regular'
25	>>> f.FamilyName
26	'TestFont'
27	>>> f.Weight
28	'Regular'
29	>>> f.XHeight
30	500
31	>>> f.Ascender
32	750
33	>>>
34	>>> # Attributes and items can also be set
35	>>> f[("A","V")] = -150 # Tighten kerning
36	>>> f.FontName = "TestFont Squished"
37	>>>
38	>>> # And the font written out again (remove the # in front)
39	>>> #f.write("testfont-squished.afm")
40
41.. rubric:: Footnotes
42
43.. [#f1] `Adobe Technote 5004 <https://www.adobe.com/content/dam/acom/en/devnet/font/pdfs/5004.AFM_Spec.pdf>`_,
44   Adobe Font Metrics File Format Specification.
45
46"""
47
48
49import re
50
51# every single line starts with a "word"
52identifierRE = re.compile(r"^([A-Za-z]+).*")
53
54# regular expression to parse char lines
55charRE = re.compile(
56		r"(-?\d+)"			# charnum
57		r"\s*;\s*WX\s+"			# ; WX
58		r"(-?\d+)"			# width
59		r"\s*;\s*N\s+"			# ; N
60		r"([.A-Za-z0-9_]+)"		# charname
61		r"\s*;\s*B\s+"			# ; B
62		r"(-?\d+)"			# left
63		r"\s+"
64		r"(-?\d+)"			# bottom
65		r"\s+"
66		r"(-?\d+)"			# right
67		r"\s+"
68		r"(-?\d+)"			# top
69		r"\s*;\s*"			# ;
70		)
71
72# regular expression to parse kerning lines
73kernRE = re.compile(
74		r"([.A-Za-z0-9_]+)"		# leftchar
75		r"\s+"
76		r"([.A-Za-z0-9_]+)"		# rightchar
77		r"\s+"
78		r"(-?\d+)"			# value
79		r"\s*"
80		)
81
82# regular expressions to parse composite info lines of the form:
83# Aacute 2 ; PCC A 0 0 ; PCC acute 182 211 ;
84compositeRE = re.compile(
85		r"([.A-Za-z0-9_]+)"		# char name
86		r"\s+"
87		r"(\d+)"				# number of parts
88		r"\s*;\s*"
89		)
90componentRE = re.compile(
91		r"PCC\s+"			# PPC
92		r"([.A-Za-z0-9_]+)"		# base char name
93		r"\s+"
94		r"(-?\d+)"			# x offset
95		r"\s+"
96		r"(-?\d+)"			# y offset
97		r"\s*;\s*"
98		)
99
100preferredAttributeOrder = [
101		"FontName",
102		"FullName",
103		"FamilyName",
104		"Weight",
105		"ItalicAngle",
106		"IsFixedPitch",
107		"FontBBox",
108		"UnderlinePosition",
109		"UnderlineThickness",
110		"Version",
111		"Notice",
112		"EncodingScheme",
113		"CapHeight",
114		"XHeight",
115		"Ascender",
116		"Descender",
117]
118
119
120class error(Exception):
121	pass
122
123
124class AFM(object):
125
126	_attrs = None
127
128	_keywords = ['StartFontMetrics',
129			'EndFontMetrics',
130			'StartCharMetrics',
131			'EndCharMetrics',
132			'StartKernData',
133			'StartKernPairs',
134			'EndKernPairs',
135			'EndKernData',
136			'StartComposites',
137			'EndComposites',
138			]
139
140	def __init__(self, path=None):
141		"""AFM file reader.
142
143		Instantiating an object with a path name will cause the file to be opened,
144		read, and parsed. Alternatively the path can be left unspecified, and a
145		file can be parsed later with the :meth:`read` method."""
146		self._attrs = {}
147		self._chars = {}
148		self._kerning = {}
149		self._index = {}
150		self._comments = []
151		self._composites = {}
152		if path is not None:
153			self.read(path)
154
155	def read(self, path):
156		"""Opens, reads and parses a file."""
157		lines = readlines(path)
158		for line in lines:
159			if not line.strip():
160				continue
161			m = identifierRE.match(line)
162			if m is None:
163				raise error("syntax error in AFM file: " + repr(line))
164
165			pos = m.regs[1][1]
166			word = line[:pos]
167			rest = line[pos:].strip()
168			if word in self._keywords:
169				continue
170			if word == "C":
171				self.parsechar(rest)
172			elif word == "KPX":
173				self.parsekernpair(rest)
174			elif word == "CC":
175				self.parsecomposite(rest)
176			else:
177				self.parseattr(word, rest)
178
179	def parsechar(self, rest):
180		m = charRE.match(rest)
181		if m is None:
182			raise error("syntax error in AFM file: " + repr(rest))
183		things = []
184		for fr, to in m.regs[1:]:
185			things.append(rest[fr:to])
186		charname = things[2]
187		del things[2]
188		charnum, width, l, b, r, t = (int(thing) for thing in things)
189		self._chars[charname] = charnum, width, (l, b, r, t)
190
191	def parsekernpair(self, rest):
192		m = kernRE.match(rest)
193		if m is None:
194			raise error("syntax error in AFM file: " + repr(rest))
195		things = []
196		for fr, to in m.regs[1:]:
197			things.append(rest[fr:to])
198		leftchar, rightchar, value = things
199		value = int(value)
200		self._kerning[(leftchar, rightchar)] = value
201
202	def parseattr(self, word, rest):
203		if word == "FontBBox":
204			l, b, r, t = [int(thing) for thing in rest.split()]
205			self._attrs[word] = l, b, r, t
206		elif word == "Comment":
207			self._comments.append(rest)
208		else:
209			try:
210				value = int(rest)
211			except (ValueError, OverflowError):
212				self._attrs[word] = rest
213			else:
214				self._attrs[word] = value
215
216	def parsecomposite(self, rest):
217		m = compositeRE.match(rest)
218		if m is None:
219			raise error("syntax error in AFM file: " + repr(rest))
220		charname = m.group(1)
221		ncomponents = int(m.group(2))
222		rest = rest[m.regs[0][1]:]
223		components = []
224		while True:
225			m = componentRE.match(rest)
226			if m is None:
227				raise error("syntax error in AFM file: " + repr(rest))
228			basechar = m.group(1)
229			xoffset = int(m.group(2))
230			yoffset = int(m.group(3))
231			components.append((basechar, xoffset, yoffset))
232			rest = rest[m.regs[0][1]:]
233			if not rest:
234				break
235		assert len(components) == ncomponents
236		self._composites[charname] = components
237
238	def write(self, path, sep='\r'):
239		"""Writes out an AFM font to the given path."""
240		import time
241		lines = [	"StartFontMetrics 2.0",
242				"Comment Generated by afmLib; at %s" % (
243						time.strftime("%m/%d/%Y %H:%M:%S",
244						time.localtime(time.time())))]
245
246		# write comments, assuming (possibly wrongly!) they should
247		# all appear at the top
248		for comment in self._comments:
249			lines.append("Comment " + comment)
250
251		# write attributes, first the ones we know about, in
252		# a preferred order
253		attrs = self._attrs
254		for attr in preferredAttributeOrder:
255			if attr in attrs:
256				value = attrs[attr]
257				if attr == "FontBBox":
258					value = "%s %s %s %s" % value
259				lines.append(attr + " " + str(value))
260		# then write the attributes we don't know about,
261		# in alphabetical order
262		items = sorted(attrs.items())
263		for attr, value in items:
264			if attr in preferredAttributeOrder:
265				continue
266			lines.append(attr + " " + str(value))
267
268		# write char metrics
269		lines.append("StartCharMetrics " + repr(len(self._chars)))
270		items = [(charnum, (charname, width, box)) for charname, (charnum, width, box) in self._chars.items()]
271
272		def myKey(a):
273			"""Custom key function to make sure unencoded chars (-1)
274			end up at the end of the list after sorting."""
275			if a[0] == -1:
276				a = (0xffff,) + a[1:]  # 0xffff is an arbitrary large number
277			return a
278		items.sort(key=myKey)
279
280		for charnum, (charname, width, (l, b, r, t)) in items:
281			lines.append("C %d ; WX %d ; N %s ; B %d %d %d %d ;" %
282					(charnum, width, charname, l, b, r, t))
283		lines.append("EndCharMetrics")
284
285		# write kerning info
286		lines.append("StartKernData")
287		lines.append("StartKernPairs " + repr(len(self._kerning)))
288		items = sorted(self._kerning.items())
289		for (leftchar, rightchar), value in items:
290			lines.append("KPX %s %s %d" % (leftchar, rightchar, value))
291		lines.append("EndKernPairs")
292		lines.append("EndKernData")
293
294		if self._composites:
295			composites = sorted(self._composites.items())
296			lines.append("StartComposites %s" % len(self._composites))
297			for charname, components in composites:
298				line = "CC %s %s ;" % (charname, len(components))
299				for basechar, xoffset, yoffset in components:
300					line = line + " PCC %s %s %s ;" % (basechar, xoffset, yoffset)
301				lines.append(line)
302			lines.append("EndComposites")
303
304		lines.append("EndFontMetrics")
305
306		writelines(path, lines, sep)
307
308	def has_kernpair(self, pair):
309		"""Returns `True` if the given glyph pair (specified as a tuple) exists
310		in the kerning dictionary."""
311		return pair in self._kerning
312
313	def kernpairs(self):
314		"""Returns a list of all kern pairs in the kerning dictionary."""
315		return list(self._kerning.keys())
316
317	def has_char(self, char):
318		"""Returns `True` if the given glyph exists in the font."""
319		return char in self._chars
320
321	def chars(self):
322		"""Returns a list of all glyph names in the font."""
323		return list(self._chars.keys())
324
325	def comments(self):
326		"""Returns all comments from the file."""
327		return self._comments
328
329	def addComment(self, comment):
330		"""Adds a new comment to the file."""
331		self._comments.append(comment)
332
333	def addComposite(self, glyphName, components):
334		"""Specifies that the glyph `glyphName` is made up of the given components.
335		The components list should be of the following form::
336
337			[
338				(glyphname, xOffset, yOffset),
339				...
340			]
341
342		"""
343		self._composites[glyphName] = components
344
345	def __getattr__(self, attr):
346		if attr in self._attrs:
347			return self._attrs[attr]
348		else:
349			raise AttributeError(attr)
350
351	def __setattr__(self, attr, value):
352		# all attrs *not* starting with "_" are consider to be AFM keywords
353		if attr[:1] == "_":
354			self.__dict__[attr] = value
355		else:
356			self._attrs[attr] = value
357
358	def __delattr__(self, attr):
359		# all attrs *not* starting with "_" are consider to be AFM keywords
360		if attr[:1] == "_":
361			try:
362				del self.__dict__[attr]
363			except KeyError:
364				raise AttributeError(attr)
365		else:
366			try:
367				del self._attrs[attr]
368			except KeyError:
369				raise AttributeError(attr)
370
371	def __getitem__(self, key):
372		if isinstance(key, tuple):
373			# key is a tuple, return the kernpair
374			return self._kerning[key]
375		else:
376			# return the metrics instead
377			return self._chars[key]
378
379	def __setitem__(self, key, value):
380		if isinstance(key, tuple):
381			# key is a tuple, set kernpair
382			self._kerning[key] = value
383		else:
384			# set char metrics
385			self._chars[key] = value
386
387	def __delitem__(self, key):
388		if isinstance(key, tuple):
389			# key is a tuple, del kernpair
390			del self._kerning[key]
391		else:
392			# del char metrics
393			del self._chars[key]
394
395	def __repr__(self):
396		if hasattr(self, "FullName"):
397			return '<AFM object for %s>' % self.FullName
398		else:
399			return '<AFM object at %x>' % id(self)
400
401
402def readlines(path):
403	with open(path, "r", encoding="ascii") as f:
404		data = f.read()
405	return data.splitlines()
406
407def writelines(path, lines, sep='\r'):
408	with open(path, "w", encoding="ascii", newline=sep) as f:
409		f.write("\n".join(lines) + "\n")
410
411
412if __name__ == "__main__":
413	import EasyDialogs
414	path = EasyDialogs.AskFileForOpen()
415	if path:
416		afm = AFM(path)
417		char = 'A'
418		if afm.has_char(char):
419			print(afm[char])	# print charnum, width and boundingbox
420		pair = ('A', 'V')
421		if afm.has_kernpair(pair):
422			print(afm[pair])	# print kerning value for pair
423		print(afm.Version)	# various other afm entries have become attributes
424		print(afm.Weight)
425		# afm.comments() returns a list of all Comment lines found in the AFM
426		print(afm.comments())
427		#print afm.chars()
428		#print afm.kernpairs()
429		print(afm)
430		afm.write(path + ".muck")
431