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): pass
81
82
83class AFM(object):
84
85	_attrs = None
86
87	_keywords = ['StartFontMetrics',
88			'EndFontMetrics',
89			'StartCharMetrics',
90			'EndCharMetrics',
91			'StartKernData',
92			'StartKernPairs',
93			'EndKernPairs',
94			'EndKernData',
95			'StartComposites',
96			'EndComposites',
97			]
98
99	def __init__(self, path=None):
100		self._attrs = {}
101		self._chars = {}
102		self._kerning = {}
103		self._index = {}
104		self._comments = []
105		self._composites = {}
106		if path is not None:
107			self.read(path)
108
109	def read(self, path):
110		lines = readlines(path)
111		for line in lines:
112			if not line.strip():
113				continue
114			m = identifierRE.match(line)
115			if m is None:
116				raise error("syntax error in AFM file: " + repr(line))
117
118			pos = m.regs[1][1]
119			word = line[:pos]
120			rest = line[pos:].strip()
121			if word in self._keywords:
122				continue
123			if word == "C":
124				self.parsechar(rest)
125			elif word == "KPX":
126				self.parsekernpair(rest)
127			elif word == "CC":
128				self.parsecomposite(rest)
129			else:
130				self.parseattr(word, rest)
131
132	def parsechar(self, rest):
133		m = charRE.match(rest)
134		if m is None:
135			raise error("syntax error in AFM file: " + repr(rest))
136		things = []
137		for fr, to in m.regs[1:]:
138			things.append(rest[fr:to])
139		charname = things[2]
140		del things[2]
141		charnum, width, l, b, r, t = (int(thing) for thing in things)
142		self._chars[charname] = charnum, width, (l, b, r, t)
143
144	def parsekernpair(self, rest):
145		m = kernRE.match(rest)
146		if m is None:
147			raise error("syntax error in AFM file: " + repr(rest))
148		things = []
149		for fr, to in m.regs[1:]:
150			things.append(rest[fr:to])
151		leftchar, rightchar, value = things
152		value = int(value)
153		self._kerning[(leftchar, rightchar)] = value
154
155	def parseattr(self, word, rest):
156		if word == "FontBBox":
157			l, b, r, t = [int(thing) for thing in rest.split()]
158			self._attrs[word] = l, b, r, t
159		elif word == "Comment":
160			self._comments.append(rest)
161		else:
162			try:
163				value = int(rest)
164			except (ValueError, OverflowError):
165				self._attrs[word] = rest
166			else:
167				self._attrs[word] = value
168
169	def parsecomposite(self, rest):
170		m = compositeRE.match(rest)
171		if m is None:
172			raise error("syntax error in AFM file: " + repr(rest))
173		charname = m.group(1)
174		ncomponents = int(m.group(2))
175		rest = rest[m.regs[0][1]:]
176		components = []
177		while True:
178			m = componentRE.match(rest)
179			if m is None:
180				raise error("syntax error in AFM file: " + repr(rest))
181			basechar = m.group(1)
182			xoffset = int(m.group(2))
183			yoffset = int(m.group(3))
184			components.append((basechar, xoffset, yoffset))
185			rest = rest[m.regs[0][1]:]
186			if not rest:
187				break
188		assert len(components) == ncomponents
189		self._composites[charname] = components
190
191	def write(self, path, sep='\r'):
192		import time
193		lines = [	"StartFontMetrics 2.0",
194				"Comment Generated by afmLib; at %s" % (
195						time.strftime("%m/%d/%Y %H:%M:%S",
196						time.localtime(time.time())))]
197
198		# write comments, assuming (possibly wrongly!) they should
199		# all appear at the top
200		for comment in self._comments:
201			lines.append("Comment " + comment)
202
203		# write attributes, first the ones we know about, in
204		# a preferred order
205		attrs = self._attrs
206		for attr in preferredAttributeOrder:
207			if attr in attrs:
208				value = attrs[attr]
209				if attr == "FontBBox":
210					value = "%s %s %s %s" % value
211				lines.append(attr + " " + str(value))
212		# then write the attributes we don't know about,
213		# in alphabetical order
214		items = sorted(attrs.items())
215		for attr, value in items:
216			if attr in preferredAttributeOrder:
217				continue
218			lines.append(attr + " " + str(value))
219
220		# write char metrics
221		lines.append("StartCharMetrics " + repr(len(self._chars)))
222		items = [(charnum, (charname, width, box)) for charname, (charnum, width, box) in self._chars.items()]
223
224		def myKey(a):
225			"""Custom key function to make sure unencoded chars (-1)
226			end up at the end of the list after sorting."""
227			if a[0] == -1:
228				a = (0xffff,) + a[1:]  # 0xffff is an arbitrary large number
229			return a
230		items.sort(key=myKey)
231
232		for charnum, (charname, width, (l, b, r, t)) in items:
233			lines.append("C %d ; WX %d ; N %s ; B %d %d %d %d ;" %
234					(charnum, width, charname, l, b, r, t))
235		lines.append("EndCharMetrics")
236
237		# write kerning info
238		lines.append("StartKernData")
239		lines.append("StartKernPairs " + repr(len(self._kerning)))
240		items = sorted(self._kerning.items())
241		for (leftchar, rightchar), value in items:
242			lines.append("KPX %s %s %d" % (leftchar, rightchar, value))
243		lines.append("EndKernPairs")
244		lines.append("EndKernData")
245
246		if self._composites:
247			composites = sorted(self._composites.items())
248			lines.append("StartComposites %s" % len(self._composites))
249			for charname, components in composites:
250				line = "CC %s %s ;" % (charname, len(components))
251				for basechar, xoffset, yoffset in components:
252					line = line + " PCC %s %s %s ;" % (basechar, xoffset, yoffset)
253				lines.append(line)
254			lines.append("EndComposites")
255
256		lines.append("EndFontMetrics")
257
258		writelines(path, lines, sep)
259
260	def has_kernpair(self, pair):
261		return pair in self._kerning
262
263	def kernpairs(self):
264		return list(self._kerning.keys())
265
266	def has_char(self, char):
267		return char in self._chars
268
269	def chars(self):
270		return list(self._chars.keys())
271
272	def comments(self):
273		return self._comments
274
275	def addComment(self, comment):
276		self._comments.append(comment)
277
278	def addComposite(self, glyphName, components):
279		self._composites[glyphName] = components
280
281	def __getattr__(self, attr):
282		if attr in self._attrs:
283			return self._attrs[attr]
284		else:
285			raise AttributeError(attr)
286
287	def __setattr__(self, attr, value):
288		# all attrs *not* starting with "_" are consider to be AFM keywords
289		if attr[:1] == "_":
290			self.__dict__[attr] = value
291		else:
292			self._attrs[attr] = value
293
294	def __delattr__(self, attr):
295		# all attrs *not* starting with "_" are consider to be AFM keywords
296		if attr[:1] == "_":
297			try:
298				del self.__dict__[attr]
299			except KeyError:
300				raise AttributeError(attr)
301		else:
302			try:
303				del self._attrs[attr]
304			except KeyError:
305				raise AttributeError(attr)
306
307	def __getitem__(self, key):
308		if isinstance(key, tuple):
309			# key is a tuple, return the kernpair
310			return self._kerning[key]
311		else:
312			# return the metrics instead
313			return self._chars[key]
314
315	def __setitem__(self, key, value):
316		if isinstance(key, tuple):
317			# key is a tuple, set kernpair
318			self._kerning[key] = value
319		else:
320			# set char metrics
321			self._chars[key] = value
322
323	def __delitem__(self, key):
324		if isinstance(key, tuple):
325			# key is a tuple, del kernpair
326			del self._kerning[key]
327		else:
328			# del char metrics
329			del self._chars[key]
330
331	def __repr__(self):
332		if hasattr(self, "FullName"):
333			return '<AFM object for %s>' % self.FullName
334		else:
335			return '<AFM object at %x>' % id(self)
336
337
338def readlines(path):
339	f = open(path, 'rb')
340	data = f.read()
341	f.close()
342	# read any text file, regardless whether it's formatted for Mac, Unix or Dos
343	sep = ""
344	if '\r' in data:
345		sep = sep + '\r'	# mac or dos
346	if '\n' in data:
347		sep = sep + '\n'	# unix or dos
348	return data.split(sep)
349
350def writelines(path, lines, sep='\r'):
351	f = open(path, 'wb')
352	for line in lines:
353		f.write(line + sep)
354	f.close()
355
356
357
358if __name__ == "__main__":
359	import EasyDialogs
360	path = EasyDialogs.AskFileForOpen()
361	if path:
362		afm = AFM(path)
363		char = 'A'
364		if afm.has_char(char):
365			print(afm[char])	# print charnum, width and boundingbox
366		pair = ('A', 'V')
367		if afm.has_kernpair(pair):
368			print(afm[pair])	# print kerning value for pair
369		print(afm.Version)	# various other afm entries have become attributes
370		print(afm.Weight)
371		# afm.comments() returns a list of all Comment lines found in the AFM
372		print(afm.comments())
373		#print afm.chars()
374		#print afm.kernpairs()
375		print(afm)
376		afm.write(path + ".muck")
377
378