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